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:
Sergey Kozyrenko
2026-07-18 13:30:15 +07:00
co-authored by Claude Opus 4.8
parent c43c05c29b
commit e3d9999c17
2 changed files with 217 additions and 2 deletions
+215
View File
@@ -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']);
});
});
+2 -2
View File
@@ -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;