test(member-work-sync): restore boundary coverage

This commit is contained in:
777genius 2026-04-30 20:01:23 +03:00
parent 9fb9e5f66a
commit 7dc4a1976d
5 changed files with 560 additions and 0 deletions

View file

@ -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');
});
});

View file

@ -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']);
});
});

View file

@ -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
);
});
});

View 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);
});
});

View 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' },
});
}