From 7dc4a1976de6304f4acc7d231dcf60037c340b8d Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 20:01:23 +0300 Subject: [PATCH] test(member-work-sync): restore boundary coverage --- .../TeamInboxMemberWorkSyncNudgeSink.test.ts | 109 +++++++++ .../main/registerMemberWorkSyncIpc.test.ts | 218 ++++++++++++++++++ .../preload/memberWorkSyncPreload.test.ts | 78 +++++++ .../preload/electronApiMemberWorkSync.test.ts | 67 ++++++ .../api/httpClient.memberWorkSync.test.ts | 88 +++++++ 5 files changed, 560 insertions(+) create mode 100644 test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts create mode 100644 test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts create mode 100644 test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts create mode 100644 test/preload/electronApiMemberWorkSync.test.ts create mode 100644 test/renderer/api/httpClient.memberWorkSync.test.ts diff --git a/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts new file mode 100644 index 00000000..7f51aeeb --- /dev/null +++ b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts @@ -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[0]; + +function makeInput(overrides: Partial = {}): 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'); + }); +}); diff --git a/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts new file mode 100644 index 00000000..5dde059d --- /dev/null +++ b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts @@ -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; + +function makeIpcMain() { + const handlers = new Map(); + 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']); + }); +}); diff --git a/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts b/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts new file mode 100644 index 00000000..3019bca4 --- /dev/null +++ b/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts @@ -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 + ); + }); +}); diff --git a/test/preload/electronApiMemberWorkSync.test.ts b/test/preload/electronApiMemberWorkSync.test.ts new file mode 100644 index 00000000..762fce10 --- /dev/null +++ b/test/preload/electronApiMemberWorkSync.test.ts @@ -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); + }); +}); diff --git a/test/renderer/api/httpClient.memberWorkSync.test.ts b/test/renderer/api/httpClient.memberWorkSync.test.ts new file mode 100644 index 00000000..9dd707f3 --- /dev/null +++ b/test/renderer/api/httpClient.memberWorkSync.test.ts @@ -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; + let eventSourceMock: ReturnType; + + 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' }, + }); +}