Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/syncService.test.ts
2026-06-10 17:49:14 +08:00

1203 lines
37 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
db: null as TestDb | null,
ensureSchemaCalls: 0,
settings: new Map<string, unknown>(),
fetch: vi.fn(),
}));
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../system/deviceId", () => ({
getDeviceId: vi.fn(() => "device-001"),
}));
vi.mock("../../db", () => ({
ensureDigitalEmployeeSchema: () => {
mockState.ensureSchemaCalls += 1;
return true;
},
getDb: () => mockState.db,
readSetting: (key: string) => mockState.settings.get(key) ?? null,
}));
describe("digital employee sync service", () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
mockState.fetch = vi.fn();
mockState.ensureSchemaCalls = 0;
vi.stubGlobal("fetch", mockState.fetch);
mockState.settings = new Map<string, unknown>([
["step1_config", { serverHost: "https://manage.example.com" }],
["auth.username", "operator"],
["auth.saved_key", "client-key-001"],
["auth.tokens.manage.example.com", "login-token-001"],
]);
mockState.db = new TestDb();
});
afterEach(() => {
mockState.db = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("skips flushing when management credentials are incomplete", async () => {
mockState.settings.delete("auth.saved_key");
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(mockState.fetch).not.toHaveBeenCalled();
expect(status.missingCredentials).toEqual(["客户端 Key"]);
expect(status.pending).toBe(1);
expect(status.failed).toBe(0);
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
status: "pending",
attempts: 0,
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("marks entity-only acknowledgements as synced", async () => {
insertPlan("plan-1");
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{
entity_type: "plan",
entity_id: "plan-1",
remote_id: "remote-plan-1",
},
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(lastSyncRequestBody()).toMatchObject({
client_key: "client-key-001",
device_id: "device-001",
contract_version: "qimingclaw.digital_employee.sync.v1",
items: [
expect.objectContaining({
outbox_id: "plan:upsert:plan-1",
entity_type: "plan",
entity_id: "plan-1",
operation: "upsert",
contract_version: "qimingclaw.digital_employee.sync.v1",
entity_summary: {
title: "同步测试计划 plan-1",
status: "pending",
summary: "用于验证同步失败诊断的业务摘要",
},
business_view: expect.objectContaining({
entity_type: "plan",
local_id: "plan-1",
title: "同步测试计划 plan-1",
status: "pending",
objective: "用于验证同步失败诊断的业务摘要",
}),
payload: { ok: true },
}),
],
});
expect(status.pending).toBe(0);
expect(status.failed).toBe(0);
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
status: "synced",
error: null,
attempts: 1,
});
expect(readPlan("plan-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-plan-1",
sync_error: null,
});
expect(readAttempts()).toEqual([
expect.objectContaining({
outbox_id: "plan:upsert:plan-1",
attempt_no: 1,
status: "synced",
error: null,
}),
]);
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("accepts backend ReqResult acknowledgements", async () => {
insertPlan("plan-reqresult-ok");
insertOutbox("plan:upsert:plan-reqresult-ok", "plan", "plan-reqresult-ok");
mockState.fetch.mockResolvedValue(
jsonResponse({
code: "0000",
message: "success",
data: {
acked: [
{
outbox_id: "plan:upsert:plan-reqresult-ok",
entity_type: "plan",
entity_id: "plan-reqresult-ok",
remote_id: "remote-plan-reqresult-ok",
},
],
},
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.lastSyncError).toBeNull();
expect(status.pending).toBe(0);
expect(status.failed).toBe(0);
expect(readOutbox("plan:upsert:plan-reqresult-ok")).toMatchObject({
status: "synced",
error: null,
attempts: 1,
});
expect(readPlan("plan-reqresult-ok")).toMatchObject({
sync_status: "synced",
remote_id: "remote-plan-reqresult-ok",
sync_error: null,
});
});
it("marks backend ReqResult business failures as failed", async () => {
insertPlan("plan-rejected");
insertOutbox("plan:upsert:plan-rejected", "plan", "plan-rejected");
mockState.fetch.mockResolvedValue(
jsonResponse({
code: "0001",
message: "客户端 Key header 缺失",
data: null,
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.lastSyncError).toBe("客户端 Key header 缺失");
expect(status.pending).toBe(0);
expect(status.failed).toBe(1);
expect(readOutbox("plan:upsert:plan-rejected")).toMatchObject({
status: "failed",
error: "客户端 Key header 缺失",
attempts: 1,
});
expect(readPlan("plan-rejected")).toMatchObject({
sync_status: "failed",
sync_error: "客户端 Key header 缺失",
});
expect(readAttempts()).toEqual([
expect.objectContaining({
outbox_id: "plan:upsert:plan-rejected",
attempt_no: 1,
status: "failed",
error: "客户端 Key header 缺失",
}),
]);
});
it("keeps unacknowledged rows retryable after a partial backend ack", async () => {
insertPlan("plan-1");
insertPlan("plan-2");
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
insertOutbox("plan:upsert:plan-2", "plan", "plan-2");
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{
outbox_id: "plan:upsert:plan-1",
entity_type: "plan",
entity_id: "plan-1",
},
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.lastSyncError).toBe(
"management sync response did not acknowledge item",
);
expect(status.failed).toBe(1);
expect(status.failureSummary).toMatchObject({
total: 1,
dueForRetry: 0,
backoff: 1,
byEntityType: [{ entityType: "plan", count: 1 }],
});
expect(readOutbox("plan:upsert:plan-1")).toMatchObject({
status: "synced",
});
expect(readOutbox("plan:upsert:plan-2")).toMatchObject({
status: "failed",
attempts: 1,
error: "management sync response did not acknowledge item",
});
expect(status.recentFailures[0]).toMatchObject({
id: "plan:upsert:plan-2",
entitySummary: {
title: "同步测试计划 plan-2",
status: "pending",
summary: "用于验证同步失败诊断的业务摘要",
},
dueForRetry: false,
managementRecordUrl:
"https://manage.example.com/system/content/content-digital-employee-sync?device_id=device-001&entity_type=plan&entity_id=plan-2&outbox_id=plan%3Aupsert%3Aplan-2",
attemptHistory: [
expect.objectContaining({
attemptNo: 1,
status: "failed",
error: "management sync response did not acknowledge item",
endpoint: "https://manage.example.com/api/digital-employee/sync/outbox",
}),
],
});
});
it("marks artifact and approval acknowledgements as synced", async () => {
insertEntity("artifact", "artifact-1");
insertEntity("approval", "approval-1");
insertOutbox("artifact:upsert:artifact-1", "artifact", "artifact-1");
insertOutbox("approval:upsert:approval-1", "approval", "approval-1");
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{
entity_type: "artifact",
entity_id: "artifact-1",
remote_id: "remote-artifact-1",
},
{
entity_type: "approval",
entity_id: "approval-1",
remote_id: "remote-approval-1",
},
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.pending).toBe(0);
expect(status.failed).toBe(0);
expect(readOutbox("artifact:upsert:artifact-1")).toMatchObject({
status: "synced",
attempts: 1,
});
expect(readOutbox("approval:upsert:approval-1")).toMatchObject({
status: "synced",
attempts: 1,
});
expect(readEntity("artifact", "artifact-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-artifact-1",
sync_error: null,
});
expect(readEntity("approval", "approval-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-approval-1",
sync_error: null,
});
});
it("syncs plan step business views and acknowledgements", async () => {
insertEntity("plan_step", "step-1");
insertOutbox(
"plan_step:upsert:step-1",
"plan_step",
"step-1",
JSON.stringify({
planId: "plan-1",
title: "整理客户列表",
status: "pending",
seq: 2,
dependsOn: ["step-0"],
preferredSkillIds: ["qimingclaw-mcp-tool-crm-crm-search"],
}),
);
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{
entity_type: "plan_step",
entity_id: "step-1",
remote_id: "remote-step-1",
},
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(lastSyncRequestBody()).toMatchObject({
items: [
expect.objectContaining({
entity_type: "plan_step",
business_view: expect.objectContaining({
entity_type: "plan_step",
local_id: "step-1",
plan_id: "plan-1",
step_id: "step-1",
title: "整理客户列表",
status: "pending",
seq: 2,
depends_on: ["step-0"],
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
}),
}),
],
});
expect(status.failed).toBe(0);
expect(readEntity("plan_step", "step-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-step-1",
sync_error: null,
});
});
it("marks plan step business failures as failed", async () => {
insertEntity("plan_step", "step-rejected");
insertOutbox("plan_step:upsert:step-rejected", "plan_step", "step-rejected");
mockState.fetch.mockResolvedValue(
jsonResponse({ code: "0001", message: "步骤同步被拒绝", data: null }),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.failed).toBe(1);
expect(readEntity("plan_step", "step-rejected")).toMatchObject({
sync_status: "failed",
sync_error: "步骤同步被拒绝",
});
});
it("syncs schedule and schedule run business views", async () => {
insertEntity("schedule", "schedule-1");
insertEntity("schedule_run", "schedule-run-1");
insertOutbox("schedule:upsert:schedule-1", "schedule", "schedule-1");
insertOutbox("schedule_run:upsert:schedule-run-1", "schedule_run", "schedule-run-1");
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{ entity_type: "schedule", entity_id: "schedule-1", remote_id: "remote-schedule-1" },
{ entity_type: "schedule_run", entity_id: "schedule-run-1", remote_id: "remote-schedule-run-1" },
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.failed).toBe(0);
expect(lastSyncRequestBody().items).toEqual([
expect.objectContaining({
entity_type: "schedule",
business_view: expect.objectContaining({
title: "同步测试计划 schedule-1",
status: "pending",
}),
}),
expect.objectContaining({
entity_type: "schedule_run",
business_view: expect.objectContaining({
title: "同步测试计划 schedule-run-1",
status: "pending",
}),
}),
]);
expect(readEntity("schedule", "schedule-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-schedule-1",
});
expect(readEntity("schedule_run", "schedule-run-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-schedule-run-1",
});
});
it("syncs memory business views and acknowledgements", async () => {
insertEntity("memory", "memory-1");
insertOutbox(
"memory:upsert:memory-1",
"memory",
"memory-1",
JSON.stringify({
key: "commit-language",
content: "用户要求提交信息使用中文,并保留相关上下文。",
category: "preference",
source: "qimingclaw-memory-service",
status: "active",
}),
);
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [{ entity_type: "memory", entity_id: "memory-1", remote_id: "remote-memory-1" }],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.failed).toBe(0);
expect(lastSyncRequestBody()).toMatchObject({
items: [
expect.objectContaining({
entity_type: "memory",
business_view: expect.objectContaining({
entity_type: "memory",
local_id: "memory-1",
key: "commit-language",
category: "preference",
source: "qimingclaw-memory-service",
status: "active",
content_preview: "用户要求提交信息使用中文,并保留相关上下文。",
}),
}),
],
});
expect(readEntity("memory", "memory-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-memory-1",
sync_error: null,
});
});
it("syncs specialized event business views for management consumption", async () => {
insertEventEntity("event-route", "route_decision", {
source: "qimingclaw-route-decision",
routeDecision: {
id: "route-1",
route_kind: "PlanStepDispatch",
intent_kind: "schedule",
policy_result: "accepted",
reason_codes: ["ready_plan_step"],
candidate_skills: ["crm-search"],
created_command_id: "governance-command-1",
},
});
insertEventEntity("event-approval", "approval_action_recorded", {
approval_id: "approval-1",
action: "mark_risk",
status: "recorded",
actor: "operator",
comment_summary: { preview: "存在风险", length: 4 },
delegate_summary: { target: "manager-1", label: "经理" },
sla_summary: { due_at: "2026-06-08T09:00:00.000Z", sla_status: "tracked" },
risk_summary: { risk_level: "high" },
});
insertEventEntity("event-artifact", "artifact_retention_marked", {
artifact_id: "artifact-1",
action: "mark_keep",
status: "recorded",
actor: "operator",
retention_status: "kept",
retention_until: "2026-07-01T00:00:00.000Z",
});
insertEventEntity("event-skill", "skill_call_rejected", {
call_id: "call-1",
source: "qimingclaw-acp",
server_id: "server-1",
tool_name: "crm.search",
skill_id: "crm-search",
decision: "rejected",
reason_codes: ["skill_disabled"],
});
insertEventEntity("event-dispatch", "plan_step_dispatch_failed", {
step_id: "step-1",
status: "failed",
reason: "computer chat failed",
});
insertEventEntity("event-governance-input", "governance_provide_task_input", {
commandKind: "provide_task_input",
commandId: "command-provide-input",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
accepted: true,
payload: {
input: "请改用客户备用手机号",
source: "notification_reply",
notification_id: "input:task-1",
plan_id: "plan-1",
task_id: "task-1",
},
result: {
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
source: "qimingclaw-task-agent",
},
});
insertEventEntity("event-notification-read", "digital_workday_notification_read", {
metadata: {
notification_id: "task:task-1",
action: "read",
},
state: {
notificationStateById: {
"task:task-1": {
status: "read",
read_at: "2026-06-07T08:01:00.000Z",
},
},
},
});
insertEventEntity("event-notification-dismiss", "digital_workday_notification_dismiss", {
metadata: {
notification_id: "task:task-2",
action: "dismiss",
},
state: {
notificationStateById: {
"task:task-2": {
status: "dismissed",
dismissed_at: "2026-06-07T08:02:00.000Z",
},
},
},
});
insertEventEntity("event-notification-reply", "digital_workday_notification_reply", {
metadata: {
notification_id: "input:task-3",
action: "reply",
},
state: {
notificationStateById: {
"input:task-3": {
status: "read",
read_at: "2026-06-07T08:03:00.000Z",
reply: "请改用客户备用手机号",
replied_at: "2026-06-07T08:03:30.000Z",
},
},
},
});
insertEventEntity("event-notification-preferences", "digital_workday_update_notification_preferences", {
metadata: {
notification_preferences: {
enabled: false,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
updated_at: "2026-06-07T08:04:00.000Z",
},
},
state: {
notificationPreferences: {
enabled: false,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
updated_at: "2026-06-07T08:04:00.000Z",
},
},
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{ entity_type: "event", entity_id: "event-route" },
{ entity_type: "event", entity_id: "event-approval" },
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-dispatch" },
{ entity_type: "event", entity_id: "event-governance-input" },
{ entity_type: "event", entity_id: "event-notification-read" },
{ entity_type: "event", entity_id: "event-notification-dismiss" },
{ entity_type: "event", entity_id: "event-notification-reply" },
{ entity_type: "event", entity_id: "event-notification-preferences" },
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
const items = lastSyncRequestBody().items as Array<{ entity_id: string; business_view: Record<string, unknown> }>;
const businessView = (eventId: string) => items.find((item) => item.entity_id === eventId)?.business_view;
expect(status.failed).toBe(0);
expect(businessView("event-route")).toMatchObject({
entity_type: "event",
local_id: "event-route",
category: "route",
kind: "route_decision",
route_decision_id: "route-1",
route_kind: "PlanStepDispatch",
intent_kind: "schedule",
policy_result: "accepted",
reason_codes: ["ready_plan_step"],
candidate_skills: ["crm-search"],
created_command_id: "governance-command-1",
attention_level: "action",
});
expect(businessView("event-approval")).toMatchObject({
category: "approval",
approval_id: "approval-1",
action: "mark_risk",
status: "recorded",
actor: "operator",
risk_level: "high",
due_at: "2026-06-08T09:00:00.000Z",
delegate_to: "manager-1",
comment_length: 4,
});
expect(businessView("event-artifact")).toMatchObject({
category: "artifact",
artifact_id: "artifact-1",
action: "mark_keep",
status: "recorded",
retention_status: "kept",
retention_until: "2026-07-01T00:00:00.000Z",
actor: "operator",
});
expect(businessView("event-skill")).toMatchObject({
category: "skill",
call_id: "call-1",
source: "qimingclaw-acp",
server_id: "server-1",
tool_name: "crm.search",
skill_id: "crm-search",
decision: "rejected",
reason_codes: ["skill_disabled"],
});
expect(businessView("event-dispatch")).toMatchObject({
category: "dispatch",
step_id: "step-1",
status: "failed",
reason: "computer chat failed",
});
expect(businessView("event-governance-input")).toMatchObject({
category: "governance",
command_kind: "provide_task_input",
plan_id: "plan-1",
task_id: "task-1",
source: "notification_reply",
notification_id: "input:task-1",
input_length: 10,
});
expect(businessView("event-governance-input")).not.toHaveProperty("input");
expect(businessView("event-notification-read")).toMatchObject({
category: "notification",
notification_id: "task:task-1",
action: "read",
status: "read",
read_at: "2026-06-07T08:01:00.000Z",
});
expect(businessView("event-notification-dismiss")).toMatchObject({
category: "notification",
notification_id: "task:task-2",
action: "dismiss",
status: "dismissed",
dismissed_at: "2026-06-07T08:02:00.000Z",
});
expect(businessView("event-notification-reply")).toMatchObject({
category: "notification",
notification_id: "input:task-3",
action: "reply",
status: "read",
read_at: "2026-06-07T08:03:00.000Z",
replied_at: "2026-06-07T08:03:30.000Z",
reply_length: 10,
});
expect(businessView("event-notification-reply")).not.toHaveProperty("reply");
expect(businessView("event-notification-preferences")).toMatchObject({
category: "notification",
preferences_enabled: false,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
});
expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced",
sync_error: null,
});
});
it("marks memory sync failures as failed", async () => {
insertEntity("memory", "memory-rejected");
insertOutbox("memory:upsert:memory-rejected", "memory", "memory-rejected");
mockState.fetch.mockResolvedValue(
jsonResponse({ code: "0001", message: "记忆同步被拒绝", data: null }),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.failed).toBe(1);
expect(readEntity("memory", "memory-rejected")).toMatchObject({
sync_status: "failed",
sync_error: "记忆同步被拒绝",
});
});
});
function insertPlan(id: string): void {
insertEntity("plan", id);
}
function insertEntity(entityType: string, id: string): void {
entityMap(entityType)?.set(id, {
id,
title: `同步测试计划 ${id}`,
status: "pending",
objective: "用于验证同步失败诊断的业务摘要",
payload: "{}",
remote_id: null,
sync_status: "pending",
sync_error: null,
last_synced_at: null,
});
}
function insertEventEntity(id: string, kind: string, payload: Record<string, unknown>): void {
mockState.db?.events.set(id, {
id,
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
kind,
message: `事件 ${kind}`,
payload: JSON.stringify(payload),
remote_id: null,
sync_status: "pending",
sync_error: null,
last_synced_at: null,
occurred_at: "2026-06-07T07:59:00.000Z",
});
insertOutbox(`event:insert:${id}`, "event", id, JSON.stringify({
event_id: id,
kind,
message: `事件 ${kind}`,
plan_id: "plan-1",
task_id: "task-1",
occurred_at: "2026-06-07T07:59:00.000Z",
}), "insert");
}
function insertOutbox(
id: string,
entityType: string,
entityId: string,
payload = '{"ok":true}',
operation = "upsert",
): void {
mockState.db?.outbox.set(id, {
id,
entity_type: entityType,
entity_id: entityId,
operation,
payload,
status: "pending",
attempts: 0,
last_attempt_at: null,
error: null,
created_at: "2026-06-07T07:55:00.000Z",
updated_at: "2026-06-07T07:55:00.000Z",
});
}
function readOutbox(id: string): Record<string, unknown> | undefined {
return mockState.db?.outbox.get(id);
}
function readPlan(id: string): Record<string, unknown> | undefined {
return readEntity("plan", id);
}
function readEntity(
entityType: string,
id: string,
): Record<string, unknown> | undefined {
return entityMap(entityType)?.get(id);
}
function entityMap(
entityType: string,
): Map<string, TestSyncEntityRow> | undefined {
if (!mockState.db) return undefined;
if (entityType === "plan") return mockState.db.plans;
if (entityType === "plan_step") return mockState.db.planSteps;
if (entityType === "schedule") return mockState.db.schedules;
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
if (entityType === "artifact") return mockState.db.artifacts;
if (entityType === "approval") return mockState.db.approvals;
if (entityType === "memory") return mockState.db.memories;
if (entityType === "event") return mockState.db.events;
return undefined;
}
function readAttempts(): Array<Record<string, unknown>> {
return mockState.db?.attempts.map((attempt) => ({ ...attempt })) ?? [];
}
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
} as unknown as Response;
}
function lastSyncRequestBody(): Record<string, unknown> {
const init = mockState.fetch.mock.calls.at(-1)?.[1] as RequestInit | undefined;
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
}
interface TestOutboxRow {
id: string;
entity_type: string;
entity_id: string;
operation: string;
payload: string;
status: string;
attempts: number;
last_attempt_at: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
interface TestSyncEntityRow {
id: string;
title?: string;
status?: string;
objective?: string | null;
payload?: string;
plan_id?: string | null;
task_id?: string | null;
run_id?: string | null;
kind?: string;
message?: string;
occurred_at?: string;
remote_id: string | null;
sync_status: string;
sync_error: string | null;
last_synced_at: string | null;
}
interface TestAttemptRow {
id: number;
outbox_id: string;
entity_type: string;
entity_id: string;
operation: string;
attempt_no: number;
status: string;
error: string | null;
endpoint: string | null;
started_at: string;
finished_at: string;
}
class TestDb {
outbox = new Map<string, TestOutboxRow>();
plans = new Map<string, TestSyncEntityRow>();
planSteps = new Map<string, TestSyncEntityRow>();
schedules = new Map<string, TestSyncEntityRow>();
scheduleRuns = new Map<string, TestSyncEntityRow>();
artifacts = new Map<string, TestSyncEntityRow>();
approvals = new Map<string, TestSyncEntityRow>();
memories = new Map<string, TestSyncEntityRow>();
events = new Map<string, TestSyncEntityRow>();
attempts: TestAttemptRow[] = [];
private nextAttemptId = 1;
transaction<T>(callback: () => T): () => T {
return callback;
}
prepare(sql: string): {
all: (...args: unknown[]) => unknown[];
get: (...args: unknown[]) => unknown;
run: (...args: unknown[]) => unknown;
} {
const normalized = sql.replace(/\s+/g, " ").trim();
return {
all: (...args: unknown[]) => this.all(normalized, args),
get: (...args: unknown[]) => this.get(normalized, args),
run: (...args: unknown[]) => this.run(normalized, args),
};
}
private all(sql: string, args: unknown[]): unknown[] {
if (
sql.includes("FROM digital_sync_outbox") &&
sql.includes("status IN ('pending', 'failed')")
) {
const limit = Number(args[0]);
return Array.from(this.outbox.values())
.filter((row) => row.status === "pending" || row.status === "failed")
.sort((left, right) => left.created_at.localeCompare(right.created_at))
.slice(0, limit)
.map(copyRow);
}
if (
sql.includes("FROM digital_sync_outbox") &&
sql.includes("WHERE status = 'failed'") &&
sql.includes("SELECT entity_type")
) {
return Array.from(this.outbox.values())
.filter((row) => row.status === "failed")
.map((row) => ({
entity_type: row.entity_type,
attempts: row.attempts,
last_attempt_at: row.last_attempt_at,
}));
}
if (
sql.includes("FROM digital_sync_outbox") &&
sql.includes("WHERE status = 'failed'") &&
sql.includes("ORDER BY COALESCE")
) {
const limit = Number(args[0]);
return Array.from(this.outbox.values())
.filter((row) => row.status === "failed")
.sort((left, right) =>
recentFailureSortKey(right).localeCompare(recentFailureSortKey(left)),
)
.slice(0, limit)
.map(copyRow);
}
if (sql.includes("FROM digital_sync_attempts")) {
const [outboxId, limit] = args as [string, number];
return this.attempts
.filter((row) => row.outbox_id === outboxId)
.sort((left, right) => {
const byTime = right.finished_at.localeCompare(left.finished_at);
return byTime || right.id - left.id;
})
.slice(0, limit)
.map((row) => ({
attempt_no: row.attempt_no,
status: row.status,
error: row.error,
endpoint: row.endpoint,
started_at: row.started_at,
finished_at: row.finished_at,
}));
}
throw new Error(`Unhandled all query: ${sql}`);
}
private get(sql: string, args: unknown[]): unknown {
if (sql.startsWith("SELECT title, status, objective, payload FROM digital_plans")) {
const [id] = args as [string];
return this.plans.get(id);
}
if (sql.startsWith("SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts")) {
const [id] = args as [string];
return this.artifacts.get(id);
}
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_approvals")) {
const [id] = args as [string];
return this.approvals.get(id);
}
if (sql.startsWith("SELECT key AS title, status, content AS summary, payload FROM digital_memories")) {
const [id] = args as [string];
return this.memories.get(id);
}
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_plan_steps")) {
const [id] = args as [string];
return this.planSteps.get(id);
}
if (sql.startsWith("SELECT name AS title, status, description AS objective, payload FROM digital_schedules")) {
const [id] = args as [string];
return this.schedules.get(id);
}
if (sql.startsWith("SELECT id AS title, status, error AS objective, payload FROM digital_schedule_runs")) {
const [id] = args as [string];
return this.scheduleRuns.get(id);
}
if (sql.startsWith("SELECT kind AS title, kind AS status, message, payload FROM digital_events")) {
const [id] = args as [string];
const row = this.events.get(id);
if (!row) return undefined;
return {
title: row.kind,
status: row.kind,
message: row.message,
payload: row.payload,
};
}
if (sql.startsWith("SELECT plan_id, task_id, run_id, kind, message, payload, occurred_at FROM digital_events")) {
const [id] = args as [string];
return this.events.get(id);
}
if (
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
) {
const [status] = args;
return {
count: Array.from(this.outbox.values()).filter(
(row) => row.status === status,
).length,
};
}
throw new Error(`Unhandled get query: ${sql}`);
}
private run(sql: string, args: unknown[]): unknown {
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'syncing'")) {
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
const row = this.outbox.get(id);
if (row) {
row.status = "syncing";
row.attempts += 1;
row.last_attempt_at = lastAttemptAt;
row.updated_at = updatedAt;
}
return { changes: row ? 1 : 0 };
}
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'synced'")) {
const [updatedAt, id] = args as [string, string];
const row = this.outbox.get(id);
if (row) {
row.status = "synced";
row.error = null;
row.updated_at = updatedAt;
}
return { changes: row ? 1 : 0 };
}
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'failed'")) {
const [error, updatedAt, id] = args as [string, string, string];
const row = this.outbox.get(id);
if (row) {
row.status = "failed";
row.error = error;
row.updated_at = updatedAt;
}
return { changes: row ? 1 : 0 };
}
if (sql.startsWith("INSERT INTO digital_sync_attempts")) {
const [
outboxId,
entityType,
entityId,
operation,
attemptNo,
status,
error,
endpoint,
startedAt,
finishedAt,
] = args as [
string,
string,
string,
string,
number,
string,
string | null,
string | null,
string,
string,
];
this.attempts.push({
id: this.nextAttemptId,
outbox_id: outboxId,
entity_type: entityType,
entity_id: entityId,
operation,
attempt_no: attemptNo,
status,
error,
endpoint,
started_at: startedAt,
finished_at: finishedAt,
});
this.nextAttemptId += 1;
return { changes: 1 };
}
if (sql.startsWith("DELETE FROM digital_sync_attempts WHERE finished_at < ?")) {
const [cutoff] = args as [string];
const before = this.attempts.length;
this.attempts = this.attempts.filter((row) => row.finished_at >= cutoff);
return { changes: before - this.attempts.length };
}
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
row.sync_status = "synced";
row.remote_id = remoteId ?? row.remote_id;
row.last_synced_at = lastSyncedAt;
row.sync_error = null;
}
return { changes: row ? 1 : 0 };
}
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
const [syncError, id] = args as [string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
row.sync_status = "failed";
row.sync_error = syncError;
}
return { changes: row ? 1 : 0 };
}
throw new Error(`Unhandled run query: ${sql}`);
}
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
if (sql.startsWith("UPDATE digital_events")) return this.events;
if (sql.startsWith("UPDATE digital_schedule_runs")) return this.scheduleRuns;
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
return this.plans;
}
}
function copyRow<T extends object>(row: T): T {
return { ...row };
}
function recentFailureSortKey(row: TestOutboxRow): string {
return row.last_attempt_at || row.updated_at || row.created_at;
}