test(member-work-sync): restore boundary coverage
This commit is contained in:
parent
9fb9e5f66a
commit
7dc4a1976d
5 changed files with 560 additions and 0 deletions
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { TeamInboxMemberWorkSyncNudgeSink } from '@features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink';
|
||||||
|
|
||||||
|
import type { MemberWorkSyncInboxNudgePort } from '@features/member-work-sync/core/application';
|
||||||
|
|
||||||
|
type NudgeInput = Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0];
|
||||||
|
|
||||||
|
function makeInput(overrides: Partial<NudgeInput> = {}): NudgeInput {
|
||||||
|
return {
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
messageId: 'member-work-sync:team-a:bob:agenda-v1-test',
|
||||||
|
payloadHash: 'payload-hash',
|
||||||
|
timestamp: '2026-04-29T00:00:00.000Z',
|
||||||
|
payload: {
|
||||||
|
from: 'system',
|
||||||
|
to: 'bob',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
source: 'member-work-sync',
|
||||||
|
actionMode: 'do',
|
||||||
|
text: 'Please reconcile your current work state.',
|
||||||
|
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
||||||
|
it('returns inserted=false when the inbox already contains the stable messageId', async () => {
|
||||||
|
const input = makeInput();
|
||||||
|
const inboxReader = {
|
||||||
|
getMessagesFor: vi.fn(async () => [{ messageId: input.messageId }]),
|
||||||
|
};
|
||||||
|
const inboxWriter = {
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
|
||||||
|
|
||||||
|
await expect(sink.insertIfAbsent(input)).resolves.toEqual({
|
||||||
|
inserted: false,
|
||||||
|
messageId: input.messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inboxReader.getMessagesFor).toHaveBeenCalledWith('team-a', 'bob');
|
||||||
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a system notification inbox message for a new nudge', async () => {
|
||||||
|
const input = makeInput();
|
||||||
|
const inboxReader = {
|
||||||
|
getMessagesFor: vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
const inboxWriter = {
|
||||||
|
sendMessage: vi.fn(async () => ({ messageId: input.messageId })),
|
||||||
|
};
|
||||||
|
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
|
||||||
|
|
||||||
|
await expect(sink.insertIfAbsent(input)).resolves.toEqual({
|
||||||
|
inserted: true,
|
||||||
|
messageId: input.messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inboxWriter.sendMessage).toHaveBeenCalledWith('team-a', {
|
||||||
|
member: 'bob',
|
||||||
|
from: 'system',
|
||||||
|
to: 'bob',
|
||||||
|
messageId: input.messageId,
|
||||||
|
timestamp: input.timestamp,
|
||||||
|
text: input.payload.text,
|
||||||
|
taskRefs: input.payload.taskRefs,
|
||||||
|
actionMode: 'do',
|
||||||
|
summary: 'Work sync check',
|
||||||
|
source: 'system_notification',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates reader failures so dispatch can classify the attempt', async () => {
|
||||||
|
const input = makeInput();
|
||||||
|
const inboxReader = {
|
||||||
|
getMessagesFor: vi.fn(async () => {
|
||||||
|
throw new Error('reader failed');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const inboxWriter = {
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
|
||||||
|
|
||||||
|
await expect(sink.insertIfAbsent(input)).rejects.toThrow('reader failed');
|
||||||
|
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates writer failures so dispatch can retry or mark terminal', async () => {
|
||||||
|
const input = makeInput();
|
||||||
|
const inboxReader = {
|
||||||
|
getMessagesFor: vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
const inboxWriter = {
|
||||||
|
sendMessage: vi.fn(async () => {
|
||||||
|
throw new Error('writer failed');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
|
||||||
|
|
||||||
|
await expect(sink.insertIfAbsent(input)).rejects.toThrow('writer failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MEMBER_WORK_SYNC_GET_METRICS,
|
||||||
|
MEMBER_WORK_SYNC_GET_STATUS,
|
||||||
|
MEMBER_WORK_SYNC_REPORT,
|
||||||
|
} from '@features/member-work-sync/contracts';
|
||||||
|
import { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc } from '@features/member-work-sync/main';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberWorkSyncMetricsRequest,
|
||||||
|
MemberWorkSyncReportRequest,
|
||||||
|
MemberWorkSyncStatusRequest,
|
||||||
|
} from '@features/member-work-sync/contracts';
|
||||||
|
import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main';
|
||||||
|
import type { IpcMain } from 'electron';
|
||||||
|
|
||||||
|
vi.mock('@shared/utils/logger', () => ({
|
||||||
|
createLogger: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type IpcHandler = (event: unknown, request: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
|
function makeIpcMain() {
|
||||||
|
const handlers = new Map<string, IpcHandler>();
|
||||||
|
const ipcMain = {
|
||||||
|
handle: vi.fn((channel: string, handler: IpcHandler) => {
|
||||||
|
handlers.set(channel, handler);
|
||||||
|
}),
|
||||||
|
removeHandler: vi.fn((channel: string) => {
|
||||||
|
handlers.delete(channel);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handlers, ipcMain: ipcMain as unknown as IpcMain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFeature(): MemberWorkSyncFeatureFacade {
|
||||||
|
return {
|
||||||
|
getStatus: vi.fn(async (request) => ({
|
||||||
|
teamName: request.teamName,
|
||||||
|
memberName: request.memberName,
|
||||||
|
state: 'unknown' as const,
|
||||||
|
agenda: {
|
||||||
|
teamName: request.teamName,
|
||||||
|
memberName: request.memberName,
|
||||||
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
|
fingerprint: 'agenda:v1:test',
|
||||||
|
items: [],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
|
diagnostics: [],
|
||||||
|
})),
|
||||||
|
getMetrics: vi.fn(async (request) => ({
|
||||||
|
teamName: request.teamName,
|
||||||
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
|
memberCount: 0,
|
||||||
|
stateCounts: {
|
||||||
|
blocked: 0,
|
||||||
|
caught_up: 0,
|
||||||
|
inactive: 0,
|
||||||
|
needs_sync: 0,
|
||||||
|
still_working: 0,
|
||||||
|
unknown: 0,
|
||||||
|
},
|
||||||
|
actionableItemCount: 0,
|
||||||
|
wouldNudgeCount: 0,
|
||||||
|
fingerprintChangeCount: 0,
|
||||||
|
reportAcceptedCount: 0,
|
||||||
|
reportRejectedCount: 0,
|
||||||
|
recentEvents: [],
|
||||||
|
phase2Readiness: {
|
||||||
|
state: 'collecting_shadow_data' as const,
|
||||||
|
reasons: ['insufficient_members' as const],
|
||||||
|
thresholds: {
|
||||||
|
maxFingerprintChangesPerMemberHour: 4,
|
||||||
|
maxReportRejectionRate: 0.2,
|
||||||
|
maxWouldNudgesPerMemberHour: 2,
|
||||||
|
minObservationHours: 24,
|
||||||
|
minObservedMembers: 3,
|
||||||
|
minStatusEvents: 20,
|
||||||
|
},
|
||||||
|
rates: {
|
||||||
|
fingerprintChangesPerMemberHour: 0,
|
||||||
|
observationHours: 0,
|
||||||
|
reportRejectionRate: 0,
|
||||||
|
statusEventCount: 0,
|
||||||
|
wouldNudgesPerMemberHour: 0,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
report: vi.fn(async (request) => ({
|
||||||
|
accepted: true,
|
||||||
|
code: 'accepted',
|
||||||
|
message: 'Report accepted.',
|
||||||
|
status: {
|
||||||
|
teamName: request.teamName,
|
||||||
|
memberName: request.memberName,
|
||||||
|
state: request.state,
|
||||||
|
agenda: {
|
||||||
|
teamName: request.teamName,
|
||||||
|
memberName: request.memberName,
|
||||||
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
|
fingerprint: request.agendaFingerprint,
|
||||||
|
items: [],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
noteTeamChange: vi.fn(),
|
||||||
|
enqueueStartupScan: vi.fn(),
|
||||||
|
replayPendingReports: vi.fn(),
|
||||||
|
dispatchDueNudges: vi.fn(),
|
||||||
|
buildRuntimeTurnSettledHookSettings: vi.fn(),
|
||||||
|
buildRuntimeTurnSettledEnvironment: vi.fn(),
|
||||||
|
drainRuntimeTurnSettledEvents: vi.fn(),
|
||||||
|
getQueueDiagnostics: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerMemberWorkSyncIpc', () => {
|
||||||
|
it('registers status, metrics and report handlers that delegate requests unchanged', async () => {
|
||||||
|
const { handlers, ipcMain } = makeIpcMain();
|
||||||
|
const feature = makeFeature();
|
||||||
|
|
||||||
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
||||||
|
|
||||||
|
expect(ipcMain.handle).toHaveBeenCalledTimes(3);
|
||||||
|
expect([...handlers.keys()].sort()).toEqual(
|
||||||
|
[MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT].sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
|
||||||
|
const metricsRequest: MemberWorkSyncMetricsRequest = { teamName: 'team-a' };
|
||||||
|
const reportRequest: MemberWorkSyncReportRequest = {
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: 'agenda:v1:test',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest)
|
||||||
|
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' });
|
||||||
|
await expect(
|
||||||
|
handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest)
|
||||||
|
).resolves.toMatchObject({ teamName: 'team-a' });
|
||||||
|
await expect(handlers.get(MEMBER_WORK_SYNC_REPORT)?.({}, reportRequest)).resolves.toMatchObject(
|
||||||
|
{ accepted: true, code: 'accepted' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(feature.getStatus).toHaveBeenCalledWith(statusRequest);
|
||||||
|
expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest);
|
||||||
|
expect(feature.report).toHaveBeenCalledWith(reportRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates feature errors so the renderer receives the real status failure', async () => {
|
||||||
|
const { handlers, ipcMain } = makeIpcMain();
|
||||||
|
const feature = makeFeature();
|
||||||
|
const failure = new Error('status failed');
|
||||||
|
vi.mocked(feature.getStatus).mockRejectedValueOnce(failure);
|
||||||
|
|
||||||
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, { teamName: 'team-a', memberName: 'bob' })
|
||||||
|
).rejects.toThrow('status failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates metrics and report errors without replacing them', async () => {
|
||||||
|
const { handlers, ipcMain } = makeIpcMain();
|
||||||
|
const feature = makeFeature();
|
||||||
|
vi.mocked(feature.getMetrics).mockRejectedValueOnce(new Error('metrics failed'));
|
||||||
|
vi.mocked(feature.report).mockRejectedValueOnce(new Error('report failed'));
|
||||||
|
|
||||||
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, { teamName: 'team-a' })
|
||||||
|
).rejects.toThrow('metrics failed');
|
||||||
|
await expect(
|
||||||
|
handlers.get(MEMBER_WORK_SYNC_REPORT)?.(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
state: 'blocked',
|
||||||
|
agendaFingerprint: 'agenda:v1:test',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).rejects.toThrow('report failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes exactly the member work sync handlers', () => {
|
||||||
|
const { handlers, ipcMain } = makeIpcMain();
|
||||||
|
const feature = makeFeature();
|
||||||
|
registerMemberWorkSyncIpc(ipcMain, feature);
|
||||||
|
handlers.set('unrelated:channel', vi.fn());
|
||||||
|
|
||||||
|
removeMemberWorkSyncIpc(ipcMain);
|
||||||
|
|
||||||
|
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3);
|
||||||
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS);
|
||||||
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS);
|
||||||
|
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT);
|
||||||
|
expect([...handlers.keys()]).toEqual(['unrelated:channel']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MEMBER_WORK_SYNC_GET_METRICS,
|
||||||
|
MEMBER_WORK_SYNC_GET_STATUS,
|
||||||
|
MEMBER_WORK_SYNC_REPORT,
|
||||||
|
} from '@features/member-work-sync/contracts';
|
||||||
|
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MemberWorkSyncMetricsRequest,
|
||||||
|
MemberWorkSyncReportRequest,
|
||||||
|
MemberWorkSyncStatusRequest,
|
||||||
|
} from '@features/member-work-sync/contracts';
|
||||||
|
import type { IpcRenderer } from 'electron';
|
||||||
|
|
||||||
|
describe('createMemberWorkSyncBridge', () => {
|
||||||
|
it('invokes the status channel without changing the request payload', async () => {
|
||||||
|
const request: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
|
||||||
|
const response = { ok: true };
|
||||||
|
const ipcRenderer = {
|
||||||
|
invoke: vi.fn(async () => response),
|
||||||
|
} as unknown as IpcRenderer;
|
||||||
|
const bridge = createMemberWorkSyncBridge(ipcRenderer);
|
||||||
|
|
||||||
|
await expect(bridge.getStatus(request)).resolves.toBe(response);
|
||||||
|
|
||||||
|
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes the metrics channel without changing the request payload', async () => {
|
||||||
|
const request: MemberWorkSyncMetricsRequest = { teamName: 'team-a' };
|
||||||
|
const response = { ok: true };
|
||||||
|
const ipcRenderer = {
|
||||||
|
invoke: vi.fn(async () => response),
|
||||||
|
} as unknown as IpcRenderer;
|
||||||
|
const bridge = createMemberWorkSyncBridge(ipcRenderer);
|
||||||
|
|
||||||
|
await expect(bridge.getMetrics(request)).resolves.toBe(response);
|
||||||
|
|
||||||
|
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes the report channel without changing the request payload', async () => {
|
||||||
|
const request: MemberWorkSyncReportRequest = {
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
state: 'blocked',
|
||||||
|
agendaFingerprint: 'agenda:v1:test',
|
||||||
|
taskIds: ['task-1'],
|
||||||
|
note: 'waiting on reviewer',
|
||||||
|
source: 'app',
|
||||||
|
};
|
||||||
|
const response = { accepted: true };
|
||||||
|
const ipcRenderer = {
|
||||||
|
invoke: vi.fn(async () => response),
|
||||||
|
} as unknown as IpcRenderer;
|
||||||
|
const bridge = createMemberWorkSyncBridge(ipcRenderer);
|
||||||
|
|
||||||
|
await expect(bridge.report(request)).resolves.toBe(response);
|
||||||
|
|
||||||
|
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates IPC rejections to the renderer caller', async () => {
|
||||||
|
const failure = new Error('ipc failed');
|
||||||
|
const ipcRenderer = {
|
||||||
|
invoke: vi.fn(async () => {
|
||||||
|
throw failure;
|
||||||
|
}),
|
||||||
|
} as unknown as IpcRenderer;
|
||||||
|
const bridge = createMemberWorkSyncBridge(ipcRenderer);
|
||||||
|
|
||||||
|
await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe(
|
||||||
|
failure
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
test/preload/electronApiMemberWorkSync.test.ts
Normal file
67
test/preload/electronApiMemberWorkSync.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { ElectronAPI } from '@shared/types/api';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const memberWorkSyncBridge = {
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
getMetrics: vi.fn(),
|
||||||
|
report: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextBridge: {
|
||||||
|
exposeInMainWorld: vi.fn(),
|
||||||
|
},
|
||||||
|
ipcRenderer: {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
},
|
||||||
|
memberWorkSyncBridge,
|
||||||
|
createMemberWorkSyncBridge: vi.fn(() => memberWorkSyncBridge),
|
||||||
|
webUtils: {
|
||||||
|
getPathForFile: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
contextBridge: mocks.contextBridge,
|
||||||
|
ipcRenderer: mocks.ipcRenderer,
|
||||||
|
webUtils: mocks.webUtils,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@features/member-work-sync/preload', () => ({
|
||||||
|
createMemberWorkSyncBridge: mocks.createMemberWorkSyncBridge,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('preload electronAPI memberWorkSync wiring', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mocks.contextBridge.exposeInMainWorld.mockClear();
|
||||||
|
mocks.createMemberWorkSyncBridge.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the member work sync bridge on the shared Electron API', async () => {
|
||||||
|
await import('../../src/preload/index');
|
||||||
|
|
||||||
|
expect(mocks.createMemberWorkSyncBridge).toHaveBeenCalledWith(mocks.ipcRenderer);
|
||||||
|
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [apiName, electronAPI] = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
ElectronAPI,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(apiName).toBe('electronAPI');
|
||||||
|
expect(electronAPI.memberWorkSync).toBe(mocks.memberWorkSyncBridge);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
test/renderer/api/httpClient.memberWorkSync.test.ts
Normal file
88
test/renderer/api/httpClient.memberWorkSync.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { HttpAPIClient } from '@renderer/api/httpClient';
|
||||||
|
|
||||||
|
class FakeEventSource {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
addEventListener = vi.fn();
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HttpAPIClient memberWorkSync', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
let eventSourceMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
|
||||||
|
eventSourceMock = vi.fn(() => new FakeEventSource());
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('EventSource', eventSourceMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps browser-mode member work sync calls to the HTTP routes', async () => {
|
||||||
|
const client = new HttpAPIClient('http://127.0.0.1:53123');
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ state: 'needs_sync' }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ memberCount: 1 }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ accepted: true }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.memberWorkSync.getStatus({ teamName: 'demo team', memberName: 'bob/qa' })
|
||||||
|
).resolves.toEqual({ state: 'needs_sync' });
|
||||||
|
await expect(client.memberWorkSync.getMetrics({ teamName: 'demo team' })).resolves.toEqual({
|
||||||
|
memberCount: 1,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.memberWorkSync.report({
|
||||||
|
teamName: 'demo team',
|
||||||
|
memberName: 'bob/qa',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: 'agenda:v1:test',
|
||||||
|
taskIds: ['task-a'],
|
||||||
|
source: 'app',
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ accepted: true });
|
||||||
|
|
||||||
|
expect(eventSourceMock).toHaveBeenCalledWith('http://127.0.0.1:53123/api/events');
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/bob%2Fqa',
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/metrics',
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/report',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
teamName: 'demo team',
|
||||||
|
memberName: 'bob/qa',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: 'agenda:v1:test',
|
||||||
|
taskIds: ['task-a'],
|
||||||
|
source: 'app',
|
||||||
|
}),
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue