From bf6370556d4c49ed10f3da1f3b3449049bb47fe6 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 8 Apr 2026 18:31:16 +0300 Subject: [PATCH] perf(team): parallelize getTeamData read phase --- src/main/services/team/TeamDataService.ts | 215 +++++-- .../services/team/TeamDataService.test.ts | 585 +++++++++++++++++- 2 files changed, 740 insertions(+), 60 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f0eb1de0..0da071cf 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -517,6 +517,62 @@ export class TeamDataService { mark('config'); const warnings: string[] = []; + interface StepResult { + value: T; + warning?: string; + completedAt: number; + } + const startReadStep = (options: { + label: string; + createFallback: () => T; + warningText?: string; + load: () => Promise; + }): Promise> => { + const { label, createFallback, warningText, load } = options; + void label; + return (async () => { + try { + const value = await load(); + return { + value, + completedAt: Date.now(), + }; + } catch { + return { + value: createFallback(), + warning: warningText, + completedAt: Date.now(), + }; + } + })(); + }; + const runWithConcurrencyLimit = (() => { + const limit = 2; + let active = 0; + const queue: Array<() => void> = []; + const releaseNext = (): void => { + if (active >= limit) return; + const next = queue.shift(); + if (next) next(); + }; + return (start: () => Promise): Promise => + new Promise((resolve, reject) => { + const run = (): void => { + active += 1; + void start() + .then(resolve, reject) + .finally(() => { + active = Math.max(0, active - 1); + releaseNext(); + }); + }; + if (active < limit) { + run(); + return; + } + queue.push(run); + }); + })(); const changePresenceEnabled = this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = @@ -536,51 +592,109 @@ export class TeamDataService { ? this.taskChangePresenceRepository!.load(teamName) : Promise.resolve(null); - let tasks: TeamTask[] = []; - try { - tasks = await this.taskReader.getTasks(teamName); - } catch { - warnings.push('Tasks failed to load'); - } - mark('tasks'); + const inboxNamesStep = startReadStep({ + label: 'inboxNames', + createFallback: () => [], + warningText: 'Inboxes failed to load', + load: () => this.inboxReader.listInboxNames(teamName), + }); + const sentMessagesStep = startReadStep({ + label: 'sentMessages', + createFallback: () => [], + warningText: 'Sent messages failed to load', + load: () => this.sentMessagesStore.readMessages(teamName), + }); + const metaMembersStep = startReadStep({ + label: 'metaMembers', + createFallback: () => [], + warningText: 'Member metadata failed to load', + load: () => this.membersMetaStore.getMembers(teamName), + }); + const kanbanStateStep = startReadStep({ + label: 'kanbanState', + createFallback: (): KanbanState => ({ + teamName, + reviewers: [], + tasks: {}, + }), + warningText: 'Kanban state failed to load', + load: () => this.kanbanManager.getState(teamName), + }); + const tasksStep = runWithConcurrencyLimit(() => + startReadStep({ + label: 'tasks', + createFallback: () => [], + warningText: 'Tasks failed to load', + load: () => this.taskReader.getTasks(teamName), + }) + ); + const messagesStep = runWithConcurrencyLimit(() => + startReadStep({ + label: 'messages', + createFallback: () => [], + warningText: 'Messages failed to load', + load: () => this.inboxReader.getMessages(teamName), + }) + ); + const leadTextsStep = runWithConcurrencyLimit(() => + startReadStep({ + label: 'leadTexts', + createFallback: () => [], + warningText: 'Lead session texts failed to load', + load: () => this.extractLeadSessionTexts(config), + }) + ); - let inboxNames: string[] = []; - try { - inboxNames = await this.inboxReader.listInboxNames(teamName); - } catch { - warnings.push('Inboxes failed to load'); - } - mark('inboxNames'); + const [ + tasksStepResult, + inboxNamesStepResult, + messagesStepResult, + leadTextsStepResult, + sentMessagesStepResult, + metaMembersStepResult, + kanbanStateStepResult, + ] = await Promise.all([ + tasksStep, + inboxNamesStep, + messagesStep, + leadTextsStep, + sentMessagesStep, + metaMembersStep, + kanbanStateStep, + ]); - let messages: InboxMessage[] = []; - try { - messages = await this.inboxReader.getMessages(teamName); - } catch { - warnings.push('Messages failed to load'); - } - mark('messages'); + // After parallelizing the top read phase, these marks no longer represent + // serial stage boundaries. They now capture the actual completion time for + // each async read relative to getTeamData() start, which keeps slow-log + // diagnostics useful without mutating marks from concurrent branches. + marks.tasks = tasksStepResult.completedAt; + marks.inboxNames = inboxNamesStepResult.completedAt; + marks.messages = messagesStepResult.completedAt; + marks.leadTexts = leadTextsStepResult.completedAt; + marks.sentMessages = sentMessagesStepResult.completedAt; + marks.metaMembers = metaMembersStepResult.completedAt; + marks.kanbanState = kanbanStateStepResult.completedAt; - let leadTexts: InboxMessage[] = []; - try { - leadTexts = await this.extractLeadSessionTexts(config); - if (leadTexts.length > 0) { - messages = [...messages, ...leadTexts]; - } - } catch { - warnings.push('Lead session texts failed to load'); - } - mark('leadTexts'); + if (tasksStepResult.warning) warnings.push(tasksStepResult.warning); + if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning); + if (messagesStepResult.warning) warnings.push(messagesStepResult.warning); + if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning); + if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning); + if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning); + if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); - let sentMessages: InboxMessage[] = []; - try { - sentMessages = await this.sentMessagesStore.readMessages(teamName); - if (sentMessages.length > 0) { - messages = [...messages, ...sentMessages]; - } - } catch { - warnings.push('Sent messages failed to load'); + let tasks: TeamTask[] = tasksStepResult.value; + let inboxNames: string[] = inboxNamesStepResult.value; + let messages: InboxMessage[] = messagesStepResult.value; + const leadTexts: InboxMessage[] = leadTextsStepResult.value; + const sentMessages: InboxMessage[] = sentMessagesStepResult.value; + + if (leadTexts.length > 0) { + messages = [...messages, ...leadTexts]; + } + if (sentMessages.length > 0) { + messages = [...messages, ...sentMessages]; } - mark('sentMessages'); // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. // This avoids double-rendering when we persist lead process messages and later load the lead JSONL. @@ -718,25 +832,8 @@ export class TeamDataService { messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - let metaMembers: TeamConfig['members'] = []; - try { - metaMembers = await this.membersMetaStore.getMembers(teamName); - } catch { - warnings.push('Member metadata failed to load'); - } - mark('metaMembers'); - - let kanbanState: KanbanState = { - teamName, - reviewers: [], - tasks: {}, - }; - try { - kanbanState = await this.kanbanManager.getState(teamName); - } catch { - warnings.push('Kanban state failed to load'); - } - mark('kanbanState'); + const metaMembers: TeamConfig['members'] = metaMembersStepResult.value; + const kanbanState: KanbanState = kanbanStateStepResult.value; mark('kanbanGc'); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 80cbf95e..4f51354a 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -8,7 +8,14 @@ import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecode import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; -import type { TeamTask } from '../../../../src/shared/types/team'; +import type { + InboxMessage, + KanbanState, + TeamConfig, + TeamData, + TeamTask, + TeamTaskWithKanban, +} from '../../../../src/shared/types/team'; const TASK_COMMENT_FORWARDING_ENV = 'CLAUDE_TEAM_TASK_COMMENT_FORWARDING'; const tempPaths: string[] = []; @@ -189,6 +196,161 @@ function createTaskCommentForwardingService(options: { return { service, inboxWriter, journal }; } +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function buildDefaultTeamConfig(overrides: Partial = {}): TeamConfig { + return { + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + leadSessionId: 'lead-1', + ...overrides, + }; +} + +function createGetTeamDataHarness(options: { + config?: TeamConfig | null; + getTasks?: () => Promise; + listInboxNames?: () => Promise; + getMessages?: () => Promise; + getMembers?: () => Promise; + getState?: () => Promise; + readMessages?: () => Promise; + resolveMembers?: ( + config: TeamConfig, + metaMembers: TeamConfig['members'], + inboxNames: string[], + tasks: TeamTaskWithKanban[], + messages: InboxMessage[] + ) => TeamData['members']; + listProcesses?: () => TeamData['processes']; + getMemberAdvisories?: () => Promise>; +} = {}) { + const getConfig = vi.fn(async () => + options.config === undefined ? buildDefaultTeamConfig() : options.config + ); + const getTasks = + options.getTasks ?? + (async () => { + return [] as TeamTask[]; + }); + const listInboxNames = + options.listInboxNames ?? + (async () => { + return [] as string[]; + }); + const getMessages = + options.getMessages ?? + (async () => { + return [] as InboxMessage[]; + }); + const getMembers = + options.getMembers ?? + (async () => { + return [] as TeamConfig['members']; + }); + const getState = + options.getState ?? + (async () => { + return { teamName: 'my-team', reviewers: [], tasks: {} } as KanbanState; + }); + const readMessages = + options.readMessages ?? + (async () => { + return [] as InboxMessage[]; + }); + const resolveMembers = options.resolveMembers ?? (() => []); + const listProcesses = options.listProcesses ?? (() => []); + const getMemberAdvisories = + options.getMemberAdvisories ?? + (async () => { + return new Map(); + }); + + const taskReader = { + getTasks: vi.fn(getTasks), + }; + const inboxReader = { + listInboxNames: vi.fn(listInboxNames), + getMessages: vi.fn(getMessages), + }; + const membersMetaStore = { + getMembers: vi.fn(getMembers), + }; + const sentMessagesStore = { + readMessages: vi.fn(readMessages), + }; + const resolveMembersSpy = vi.fn(resolveMembers); + const kanbanManager = { + getState: vi.fn(getState), + garbageCollect: vi.fn(async () => undefined), + }; + const listProcessesSpy = vi.fn(listProcesses); + const advisoryService = { + getMemberAdvisories: vi.fn(getMemberAdvisories), + }; + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig, + } as never, + taskReader as never, + inboxReader as never, + {} as never, + {} as never, + { + resolveMembers: resolveMembersSpy, + } as never, + kanbanManager as never, + {} as never, + membersMetaStore as never, + sentMessagesStore as never, + (() => + ({ + processes: { + listProcesses: listProcessesSpy, + }, + }) as never) as never, + {} as never, + {} as never, + advisoryService as never + ); + + return { + service, + getConfig, + taskReader, + inboxReader, + membersMetaStore, + sentMessagesStore, + resolveMembersSpy, + kanbanManager, + listProcessesSpy, + advisoryService, + }; +} + describe('TeamDataService', () => { it('keeps getTeamData read-only and skips kanban garbage-collect', async () => { const order: string[] = []; @@ -2824,4 +2986,425 @@ describe('TeamDataService', () => { expect(firstSpy).toHaveBeenCalledTimes(1); expect(secondSpy).toHaveBeenCalledTimes(1); }); + + it('fails fast when config is missing before any read-phase step starts', async () => { + const harness = createGetTeamDataHarness({ + config: null, + }); + + await expect(harness.service.getTeamData('missing-team')).rejects.toThrow( + 'Team not found: missing-team' + ); + + expect(harness.taskReader.getTasks).not.toHaveBeenCalled(); + expect(harness.inboxReader.listInboxNames).not.toHaveBeenCalled(); + expect(harness.inboxReader.getMessages).not.toHaveBeenCalled(); + expect(harness.membersMetaStore.getMembers).not.toHaveBeenCalled(); + expect(harness.sentMessagesStore.readMessages).not.toHaveBeenCalled(); + expect(harness.kanbanManager.getState).not.toHaveBeenCalled(); + expect(harness.listProcessesSpy).not.toHaveBeenCalled(); + }); + + it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => { + const order: string[] = []; + const tasksDeferred = createDeferred(); + const messagesDeferred = createDeferred(); + const leadTextsDeferred = createDeferred(); + + const harness = createGetTeamDataHarness({ + getTasks: async () => { + order.push('tasks:start'); + return tasksDeferred.promise; + }, + listInboxNames: async () => { + order.push('inboxNames:start'); + return []; + }, + getMessages: async () => { + order.push('messages:start'); + return messagesDeferred.promise; + }, + getMembers: async () => { + order.push('meta:start'); + return []; + }, + getState: async () => { + order.push('kanban:start'); + return { teamName: 'my-team', reviewers: [], tasks: {} }; + }, + readMessages: async () => { + order.push('sent:start'); + return []; + }, + resolveMembers: () => { + order.push('resolveMembers'); + return []; + }, + listProcesses: () => { + order.push('processes:start'); + return [ + { + id: 'proc-1', + label: 'Lead', + pid: 101, + registeredAt: '2026-04-08T12:00:00.000Z', + }, + ]; + }, + getMemberAdvisories: async () => { + order.push('runtimeAdvisories'); + return new Map(); + }, + }); + + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( + async () => { + order.push('leadTexts:start'); + return leadTextsDeferred.promise; + } + ); + + const pending = harness.service.getTeamData('my-team'); + await flushMicrotasks(); + + expect(order).toEqual( + expect.arrayContaining([ + 'inboxNames:start', + 'sent:start', + 'meta:start', + 'kanban:start', + 'tasks:start', + 'messages:start', + ]) + ); + expect(order).not.toContain('leadTexts:start'); + expect(order).not.toContain('processes:start'); + + tasksDeferred.resolve([]); + await flushMicrotasks(); + + expect(order).toContain('leadTexts:start'); + expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start')); + expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start')); + expect(order).not.toContain('processes:start'); + + messagesDeferred.resolve([]); + leadTextsDeferred.resolve([]); + + const data = await pending; + + expect(data.processes).toEqual([ + expect.objectContaining({ + id: 'proc-1', + pid: 101, + }), + ]); + expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start')); + expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start')); + }); + + it('keeps warning order deterministic even when read failures settle out of order', async () => { + const tasksDeferred = createDeferred(); + const inboxDeferred = createDeferred(); + const messagesDeferred = createDeferred(); + const leadTextsDeferred = createDeferred(); + const sentDeferred = createDeferred(); + const metaDeferred = createDeferred(); + const kanbanDeferred = createDeferred(); + + const harness = createGetTeamDataHarness({ + getTasks: async () => tasksDeferred.promise, + listInboxNames: async () => inboxDeferred.promise, + getMessages: async () => messagesDeferred.promise, + getMembers: async () => metaDeferred.promise, + getState: async () => kanbanDeferred.promise, + readMessages: async () => sentDeferred.promise, + }); + + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( + async () => leadTextsDeferred.promise + ); + + const pending = harness.service.getTeamData('my-team'); + await flushMicrotasks(); + + sentDeferred.reject(new Error('sent failed')); + kanbanDeferred.reject(new Error('kanban failed')); + tasksDeferred.reject(new Error('tasks failed')); + metaDeferred.reject(new Error('meta failed')); + inboxDeferred.reject(new Error('inbox failed')); + leadTextsDeferred.reject(new Error('lead failed')); + messagesDeferred.reject(new Error('messages failed')); + + const data = await pending; + + expect(data.warnings).toEqual([ + 'Tasks failed to load', + 'Inboxes failed to load', + 'Messages failed to load', + 'Lead session texts failed to load', + 'Sent messages failed to load', + 'Member metadata failed to load', + 'Kanban state failed to load', + ]); + }); + + it('preserves message assembly order across inbox, lead texts, and sent messages', async () => { + const harness = createGetTeamDataHarness({ + getMessages: async () => [ + { + from: 'alice', + to: 'team-lead', + text: 'Inbox update', + timestamp: '2026-04-08T12:00:01.000Z', + read: true, + source: 'inbox', + messageId: 'inbox-1', + }, + ], + readMessages: async () => [ + { + from: 'user', + to: 'team-lead', + text: '/status', + timestamp: '2026-04-08T12:00:03.000Z', + read: true, + source: 'user_sent', + messageId: 'sent-1', + }, + ], + }); + + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockResolvedValue([ + { + from: 'team-lead', + text: 'Lead summary', + timestamp: '2026-04-08T12:00:02.000Z', + read: true, + source: 'lead_session', + leadSessionId: 'lead-1', + messageId: 'lead-1', + }, + ]); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); + }); + + it('preserves assembled messages and resolver inputs when inbox messages fail', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Investigate rollout', + status: 'pending', + }; + const metaMembers = [{ name: 'alice' }]; + const inboxNames = ['alice']; + const resolveMembersSpy = vi.fn(() => []); + const harness = createGetTeamDataHarness({ + getTasks: async () => [task], + listInboxNames: async () => inboxNames, + getMessages: async () => { + throw new Error('messages failed'); + }, + getMembers: async () => metaMembers, + getState: async () => { + throw new Error('kanban failed'); + }, + readMessages: async () => [ + { + from: 'user', + to: 'team-lead', + text: '/status', + timestamp: '2026-04-08T12:00:03.000Z', + read: true, + source: 'user_sent', + messageId: 'sent-1', + }, + ], + resolveMembers: resolveMembersSpy, + }); + + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockResolvedValue([ + { + from: 'team-lead', + text: 'Lead summary', + timestamp: '2026-04-08T12:00:02.000Z', + read: true, + source: 'lead_session', + leadSessionId: 'lead-1', + messageId: 'lead-1', + }, + ]); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.warnings).toEqual( + expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load']) + ); + expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']); + expect(resolveMembersSpy).toHaveBeenCalledWith( + buildDefaultTeamConfig(), + metaMembers, + inboxNames, + [ + expect.objectContaining({ + id: 'task-1', + subject: 'Investigate rollout', + }), + ], + [ + expect.objectContaining({ messageId: 'sent-1' }), + expect.objectContaining({ messageId: 'lead-1' }), + ] + ); + }); + + it('keeps task assembly safe when kanban loading fails', async () => { + const harness = createGetTeamDataHarness({ + getTasks: async () => [ + { + id: 'task-1', + subject: 'Investigate rollout', + status: 'pending', + }, + ], + getState: async () => { + throw new Error('kanban failed'); + }, + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks).toEqual([ + expect.objectContaining({ + id: 'task-1', + subject: 'Investigate rollout', + status: 'pending', + }), + ]); + expect(data.kanbanState).toEqual({ + teamName: 'my-team', + reviewers: [], + tasks: {}, + }); + expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load'])); + }); + + it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => { + const order: string[] = []; + const tasksDeferred = createDeferred(); + const messagesDeferred = createDeferred(); + const harness = createGetTeamDataHarness({ + getTasks: async () => { + order.push('tasks:start'); + return tasksDeferred.promise; + }, + getMessages: async () => { + order.push('messages:start'); + return messagesDeferred.promise; + }, + listProcesses: () => { + order.push('processes:start'); + return []; + }, + }); + + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(() => { + order.push('leadTexts:start'); + throw new Error('lead sync fail'); + }); + + const pending = harness.service.getTeamData('my-team'); + await flushMicrotasks(); + + expect(order).not.toContain('leadTexts:start'); + + tasksDeferred.resolve([]); + await flushMicrotasks(); + + expect(order).toContain('leadTexts:start'); + + messagesDeferred.resolve([]); + const data = await pending; + + expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load'])); + expect(order).toContain('processes:start'); + }); + + it('preserves presenceIndex rejection semantics and rejects before resolveMembers', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Check change presence', + status: 'pending', + }; + const harness = createGetTeamDataHarness({ + config: buildDefaultTeamConfig({ projectPath: '/repo' }), + getTasks: async () => [task], + }); + const loadDeferred = createDeferred(); + const load = vi.fn(() => loadDeferred.promise); + + harness.service.setTaskChangePresenceServices( + { + load, + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const pending = harness.service.getTeamData('my-team'); + await flushMicrotasks(); + loadDeferred.reject(new Error('presence failed')); + + await expect(pending).rejects.toThrow('presence failed'); + expect(load).toHaveBeenCalledWith('my-team'); + expect(harness.resolveMembersSpy).not.toHaveBeenCalled(); + }); + + it('handles a synchronous light-step failure with the same degraded warning behavior', async () => { + const harness = createGetTeamDataHarness({ + getMembers: (() => { + throw new Error('meta sync fail'); + }) as never, + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.warnings).toEqual(expect.arrayContaining(['Member metadata failed to load'])); + expect(data.members).toEqual([]); + }); + + it('surfaces orchestration errors that happen after the read phase and outside step wrappers', async () => { + const harness = createGetTeamDataHarness({ + resolveMembers: () => { + throw new Error('resolver exploded'); + }, + }); + + await expect(harness.service.getTeamData('my-team')).rejects.toThrow('resolver exploded'); + }); + + it('does not crash in the slow-log path when marks come from async step completion times', async () => { + const harness = createGetTeamDataHarness(); + let now = 0; + const dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 200; + return now; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const data = await harness.service.getTeamData('my-team'); + expect(data.teamName).toBe('my-team'); + expect(warnSpy).toHaveBeenCalled(); + } finally { + dateNowSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); });