mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 20:46:31 +00:00
test(apollo): cover the subscription cache merge-link
The link that folds live subscription events into the Apollo cache (updateCacheForSubscription) had no coverage — MockedProvider replaces the whole link chain, so it can't exercise this. Export it and drive it directly against a real InMemoryCache configured like production (flow-scoped list fields keyed by flowId). Locks the routing rules: *Added appends + de-dups by id, *Created prepends (newest first — not re-sorted by id), *Deleted removes by id, *Updated merges an entity's fields in place without reordering (and appends if it's not yet cached). Also covers the two subtle bits: flowId variant isolation (an event for one flow leaves another flow's list untouched) and type-tolerant id de-dup (a numeric subscription id matches a string id from REST hydration). Also exports createSubscriptionCacheLink for future frame-level tests. Pure additive exports, no runtime change; app verified still loading. 1003 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c43c05c29b
commit
e3d9999c17
@@ -0,0 +1,215 @@
|
||||
import { gql, InMemoryCache } from '@apollo/client';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { updateCacheForSubscription } from './apollo';
|
||||
|
||||
const TERMINAL = gql`
|
||||
query T($flowId: ID!) {
|
||||
terminalLogs(flowId: $flowId) {
|
||||
id
|
||||
text
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TASKS = gql`
|
||||
query Ts($flowId: ID!) {
|
||||
tasks(flowId: $flowId) {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FLOWS = gql`
|
||||
query F {
|
||||
flows {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Mirrors the flow-scoped list fields in the production cache: keyed by flowId,
|
||||
// query writes replace wholesale (the subscription link mutates via cache.modify).
|
||||
const makeCache = () =>
|
||||
new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
fields: {
|
||||
flows: { merge: (_existing, incoming) => incoming },
|
||||
tasks: { keyArgs: ['flowId'], merge: (_existing, incoming) => incoming },
|
||||
terminalLogs: { keyArgs: ['flowId'], merge: (_existing, incoming) => incoming },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The subscription payload carries `__typename` + fields; updateCacheForSubscription's
|
||||
// param is the minimal `{ id }` contract, so route literals through this to keep
|
||||
// TypeScript's excess-property check off fresh object literals.
|
||||
const frame = <T extends { id: number | string }>(payload: T): T => payload;
|
||||
|
||||
const termIds = (cache: InMemoryCache, flowId: string) =>
|
||||
cache
|
||||
.readQuery<{ terminalLogs: { id: string }[] }>({ query: TERMINAL, variables: { flowId } })
|
||||
?.terminalLogs.map((log) => String(log.id));
|
||||
|
||||
const taskList = (cache: InMemoryCache, flowId: string) =>
|
||||
cache.readQuery<{ tasks: { id: string; status: string }[] }>({ query: TASKS, variables: { flowId } })?.tasks;
|
||||
|
||||
describe('subscription cache merge-link (updateCacheForSubscription)', () => {
|
||||
let cache: InMemoryCache;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = makeCache();
|
||||
});
|
||||
|
||||
it('appends an added log and de-dups a repeat of the same id', () => {
|
||||
cache.writeQuery({
|
||||
data: { terminalLogs: [{ __typename: 'TerminalLog', id: '1', text: 'first' }] },
|
||||
query: TERMINAL,
|
||||
variables: { flowId: '1' },
|
||||
});
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'terminalLogAdded',
|
||||
'terminalLogs',
|
||||
frame({ __typename: 'TerminalLog', id: '2', text: 'second' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
expect(termIds(cache, '1')).toEqual(['1', '2']);
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'terminalLogAdded',
|
||||
'terminalLogs',
|
||||
frame({ __typename: 'TerminalLog', id: '2', text: 'second-again' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
expect(termIds(cache, '1')).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('prepends a created task (newest first), not sorted by id', () => {
|
||||
cache.writeQuery({
|
||||
data: { tasks: [{ __typename: 'Task', id: '5', status: 'finished', title: 'old' }] },
|
||||
query: TASKS,
|
||||
variables: { flowId: '1' },
|
||||
});
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'taskCreated',
|
||||
'tasks',
|
||||
frame({ __typename: 'Task', id: '2', status: 'running', title: 'new' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
|
||||
expect(taskList(cache, '1')?.map((task) => task.id)).toEqual(['2', '5']);
|
||||
});
|
||||
|
||||
it('removes a deleted flow by id', () => {
|
||||
cache.writeQuery({
|
||||
data: {
|
||||
flows: [
|
||||
{ __typename: 'Flow', id: '1', title: 'a' },
|
||||
{ __typename: 'Flow', id: '2', title: 'b' },
|
||||
],
|
||||
},
|
||||
query: FLOWS,
|
||||
});
|
||||
|
||||
updateCacheForSubscription(cache, 'flowDeleted', 'flows', frame({ __typename: 'Flow', id: '1' }));
|
||||
|
||||
expect(cache.readQuery<{ flows: { id: string }[] }>({ query: FLOWS })?.flows.map((flow) => flow.id)).toEqual([
|
||||
'2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges fields of an updated task in place without reordering', () => {
|
||||
cache.writeQuery({
|
||||
data: {
|
||||
tasks: [
|
||||
{ __typename: 'Task', id: '1', status: 'running', title: 'a' },
|
||||
{ __typename: 'Task', id: '2', status: 'running', title: 'b' },
|
||||
],
|
||||
},
|
||||
query: TASKS,
|
||||
variables: { flowId: '1' },
|
||||
});
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'taskUpdated',
|
||||
'tasks',
|
||||
frame({ __typename: 'Task', id: '1', status: 'finished', title: 'a' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
|
||||
const tasks = taskList(cache, '1');
|
||||
expect(tasks?.map((task) => task.id)).toEqual(['1', '2']);
|
||||
expect(tasks?.find((task) => task.id === '1')?.status).toBe('finished');
|
||||
});
|
||||
|
||||
it('appends an updated task that is not yet in the cache', () => {
|
||||
cache.writeQuery({
|
||||
data: { tasks: [{ __typename: 'Task', id: '1', status: 'running', title: 'a' }] },
|
||||
query: TASKS,
|
||||
variables: { flowId: '1' },
|
||||
});
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'taskUpdated',
|
||||
'tasks',
|
||||
frame({ __typename: 'Task', id: '9', status: 'running', title: 'z' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
|
||||
expect(taskList(cache, '1')?.map((task) => task.id)).toEqual(['1', '9']);
|
||||
});
|
||||
|
||||
it('isolates the update to the matching flowId variant', () => {
|
||||
cache.writeQuery({
|
||||
data: { terminalLogs: [{ __typename: 'TerminalLog', id: '1', text: 'f1' }] },
|
||||
query: TERMINAL,
|
||||
variables: { flowId: '1' },
|
||||
});
|
||||
cache.writeQuery({
|
||||
data: { terminalLogs: [{ __typename: 'TerminalLog', id: '10', text: 'f2' }] },
|
||||
query: TERMINAL,
|
||||
variables: { flowId: '2' },
|
||||
});
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'terminalLogAdded',
|
||||
'terminalLogs',
|
||||
frame({ __typename: 'TerminalLog', id: '11', text: 'f1-new' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
|
||||
expect(termIds(cache, '1')).toEqual(['1', '11']);
|
||||
expect(termIds(cache, '2')).toEqual(['10']);
|
||||
});
|
||||
|
||||
it('de-dups across string vs number ids (REST hydration writes strings)', () => {
|
||||
cache.writeQuery({
|
||||
data: { terminalLogs: [{ __typename: 'TerminalLog', id: '5', text: 'x' }] },
|
||||
query: TERMINAL,
|
||||
variables: { flowId: '1' },
|
||||
});
|
||||
|
||||
updateCacheForSubscription(
|
||||
cache,
|
||||
'terminalLogAdded',
|
||||
'terminalLogs',
|
||||
frame({ __typename: 'TerminalLog', id: 5, text: 'x-again' }),
|
||||
{ flowId: '1' },
|
||||
);
|
||||
|
||||
expect(termIds(cache, '1')).toEqual(['5']);
|
||||
});
|
||||
});
|
||||
@@ -166,7 +166,7 @@ const cacheActionStrategies: Record<SubscriptionAction, CacheActionApplier> = {
|
||||
update: (existingArray, newRef, itemExists) => (itemExists ? existingArray : [...existingArray, newRef]),
|
||||
};
|
||||
|
||||
const updateCacheForSubscription = (
|
||||
export const updateCacheForSubscription = (
|
||||
cache: InMemoryCache,
|
||||
subscriptionName: string,
|
||||
cacheField: string,
|
||||
@@ -355,7 +355,7 @@ const createStreamingLink = (): ApolloLink => {
|
||||
});
|
||||
};
|
||||
|
||||
const createSubscriptionCacheLink = (cacheInstance: InMemoryCache): ApolloLink =>
|
||||
export const createSubscriptionCacheLink = (cacheInstance: InMemoryCache): ApolloLink =>
|
||||
createInterceptLink((result, operation) => {
|
||||
if (result.data) {
|
||||
const variables = operation.variables as Record<string, unknown> | undefined;
|
||||
|
||||
Reference in New Issue
Block a user