From e3d9999c17fbf579dbebcad7466a0a4b6188720c Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sat, 18 Jul 2026 13:30:15 +0700 Subject: [PATCH] test(apollo): cover the subscription cache merge-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/lib/apollo.test.ts | 215 ++++++++++++++++++++++++++++++++ frontend/src/lib/apollo.ts | 4 +- 2 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/apollo.test.ts diff --git a/frontend/src/lib/apollo.test.ts b/frontend/src/lib/apollo.test.ts new file mode 100644 index 00000000..10c278e5 --- /dev/null +++ b/frontend/src/lib/apollo.test.ts @@ -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 = (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']); + }); +}); diff --git a/frontend/src/lib/apollo.ts b/frontend/src/lib/apollo.ts index 991a250e..cf45de84 100644 --- a/frontend/src/lib/apollo.ts +++ b/frontend/src/lib/apollo.ts @@ -166,7 +166,7 @@ const cacheActionStrategies: Record = { 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 | undefined;