agent-ecosystem/test/main/services/team/TeamMessageFeedService.test.ts
2026-05-02 20:24:46 +03:00

223 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeamMessageFeedService } from '../../../../src/main/services/team/TeamMessageFeedService';
import type { InboxMessage, TeamConfig } from '../../../../src/shared/types/team';
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
return {
from: 'user',
to: 'jack',
text: 'Тут?',
timestamp: '2026-04-19T18:46:37.613Z',
read: true,
source: 'user_sent',
messageId: 'user-send-1',
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('TeamMessageFeedService', () => {
const config: TeamConfig = {
name: 'Signal Ops 4',
members: [{ name: 'team-lead', role: 'Lead' }],
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-19T18:46:40.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('reuses the cached feed within the cache TTL when no dirty invalidation arrives', async () => {
let inboxMessages: InboxMessage[] = [makeMessage()];
const getInboxMessages = vi.fn(async () => inboxMessages);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const first = await service.getFeed('signal-ops-4');
expect(first.messages).toHaveLength(1);
inboxMessages = [
makeMessage({
from: 'jack',
to: 'user',
text: 'Да, я тут, на связи. Что нужно сделать/проверить?',
source: 'inbox',
timestamp: '2026-04-19T18:46:43.427Z',
}),
...inboxMessages,
];
vi.setSystemTime(new Date('2026-04-19T18:46:43.000Z'));
const second = await service.getFeed('signal-ops-4');
expect(getInboxMessages).toHaveBeenCalledTimes(1);
expect(second.messages).toHaveLength(1);
});
it('refreshes the durable feed after cache expiry even when the dirty signal was missed', async () => {
let inboxMessages: InboxMessage[] = [makeMessage()];
const getInboxMessages = vi.fn(async () => inboxMessages);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
await service.getFeed('signal-ops-4');
inboxMessages = [
makeMessage({
from: 'jack',
to: 'user',
text: 'Да, я тут, на связи. Что нужно сделать/проверить?',
source: 'inbox',
timestamp: '2026-04-19T18:46:43.427Z',
}),
makeMessage(),
];
vi.setSystemTime(new Date('2026-04-19T18:46:46.500Z'));
const refreshed = await service.getFeed('signal-ops-4');
expect(getInboxMessages).toHaveBeenCalledTimes(2);
expect(
refreshed.messages.some(
(message) =>
message.from === 'jack' &&
message.to === 'user' &&
message.text.includes('Да, я тут')
)
).toBe(true);
});
it('deduplicates concurrent feed rebuilds for the same team', async () => {
const inboxRequest = createDeferred<InboxMessage[]>();
const getInboxMessages = vi.fn(() => inboxRequest.promise);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const first = service.getFeed('signal-ops-4');
const second = service.getFeed('signal-ops-4');
await Promise.resolve();
expect(getInboxMessages).toHaveBeenCalledTimes(1);
inboxRequest.resolve([makeMessage()]);
const [firstFeed, secondFeed] = await Promise.all([first, second]);
expect(firstFeed).toEqual(secondFeed);
expect(firstFeed.messages).toHaveLength(1);
});
it('does not reuse or cache a stale in-flight rebuild after invalidation', async () => {
const firstInboxRequest = createDeferred<InboxMessage[]>();
const secondInboxRequest = createDeferred<InboxMessage[]>();
const getInboxMessages = vi
.fn()
.mockImplementationOnce(() => firstInboxRequest.promise)
.mockImplementationOnce(() => secondInboxRequest.promise);
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),
getInboxMessages,
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const staleRequest = service.getFeed('signal-ops-4');
await Promise.resolve();
expect(getInboxMessages).toHaveBeenCalledTimes(1);
service.invalidate('signal-ops-4');
const freshRequest = service.getFeed('signal-ops-4');
await Promise.resolve();
expect(getInboxMessages).toHaveBeenCalledTimes(2);
secondInboxRequest.resolve([
makeMessage({
messageId: 'fresh-message',
text: 'fresh',
timestamp: '2026-04-19T18:46:45.000Z',
}),
]);
const freshFeed = await freshRequest;
expect(freshFeed.messages[0]?.messageId).toBe('fresh-message');
firstInboxRequest.resolve([
makeMessage({
messageId: 'stale-message',
text: 'stale',
timestamp: '2026-04-19T18:46:44.000Z',
}),
]);
await staleRequest;
const cachedFeed = await service.getFeed('signal-ops-4');
expect(cachedFeed.messages[0]?.messageId).toBe('fresh-message');
expect(getInboxMessages).toHaveBeenCalledTimes(2);
});
it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => {
const opencodeConfig: TeamConfig = {
name: 'relay-works-14',
description: 'relay-works-14 team for provisioning flow',
members: [
{ name: 'team-lead', role: 'Lead', providerId: 'codex' },
{
name: 'bob',
role: 'developer',
providerId: 'opencode',
model: 'openrouter/moonshotai/kimi-k2.6',
joinedAt: 1777570946947,
},
],
};
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => opencodeConfig),
getInboxMessages: vi.fn(async () => []),
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const feed = await service.getFeed('relay-works-14');
expect(feed.messages).toHaveLength(1);
expect(feed.messages[0]).toMatchObject({
from: 'team-lead',
to: 'bob',
source: 'system_notification',
messageId: 'opencode-bootstrap-start:relay-works-14:bob',
timestamp: '2026-04-30T17:42:26.947Z',
});
expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.');
expect(feed.messages[0]?.text).toContain(
'Model override for this teammate: openrouter/moonshotai/kimi-k2.6.'
);
expect(feed.messages[0]?.text).toContain(
'The team has already been created and you are being attached as a persistent teammate.'
);
});
});