From 7eafbd91c6ed5f6f395754a5179e56773400a6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 20 Jun 2026 14:29:28 +0200 Subject: [PATCH] test(server): make timeline integration test self-seed its data (#21896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `timeline-from-object-record.integration-spec.ts` is flaky depending on Jest shard composition. Its `beforeAll` scans the dev-seeded people for one with message threads and one with calendar events, and throws when none is found: ``` Expected the seeded workspace to contain a person with message threads and calendar events ``` This was observed as a deterministic failure of `server-integration-test (2)` (failed on re-run too), while the other 15 shards were green. ## Root cause The suite depends on **mutable shared fixture state** under two fragile assumptions: 1. **That no sibling suite wiped the seeded people.** `deleteAllRecords('person')` is a common pattern across the REST/GraphQL suites — `rest-api-core-find-many`, `rest-api-core-find-one`, `all-people-resolvers`, `search-resolver`, etc. — each hard-deletes every person (`DELETE FROM "...".person`) and leaves only its own handful behind, without restoring the seed. Within a shard, Jest runs files serially (`maxWorkers: 1`) ordered by file size descending (no timing cache in CI). `rest-api-core-find-many` (~16 KB, runs 2nd) executes **before** `timeline-from-object-record` (~12 KB, runs 5th), so by the time the timeline `beforeAll` runs, only 4 company-linked test people remain — none with threads or events. 2. **That the seeder's `Math.random` participant assignment** happened to land a thread and an event on a company-linked person within the first 100 results — itself non-deterministic across DB resets. It surfaced now because an unrelated PR added a new integration test file, which changed the total file set and therefore Jest's shard distribution, moving `timeline-from-object-record` and `rest-api-core-find-many` into the **same shard** for the first time. It is a latent test-isolation issue, not a product regression. ### Reproduced locally Against a DB where `rest-api-core-find-many` had already run (person count = 4), the timeline suite fails with the exact CI error; on a freshly seeded DB it passes. So the failure is purely order/seed dependent. ## Fix Make the suite self-contained: in `beforeAll` it now provisions its own graph via the GraphQL API and tears it down in `afterAll`: ``` company → person → messageThread → message → messageParticipant(personId) ↘ calendarEvent → calendarEventParticipant(personId) ``` The timeline resolvers count threads via `messageThread → messages → messageParticipants.personId` and events via `calendarEvent → calendarEventParticipants.personId`, so this graph is sufficient and minimal. The suite no longer reads any ambient seeded data, making it independent of execution order and seeding randomness. ## Validation - Self-seeding suite passes against the **polluted** DB (4 people, no seeded threads/events) — the exact CI failure condition. - Idempotent across repeated runs and leaves **no residue** (all fixtures destroyed in `afterAll`). - Full `--shard=2/16` run green except a pre-existing environmental failure (`successful-save-imap-smtp-caldav-account`, fails locally with no mail server, identical before/after this change). https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De --- _Generated by [Claude Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_ Review in cubic --- ...ine-from-object-record.integration-spec.ts | 179 +++++++++++------- 1 file changed, 108 insertions(+), 71 deletions(-) diff --git a/packages/twenty-server/test/integration/graphql/suites/timeline-from-object-record.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/timeline-from-object-record.integration-spec.ts index 0fd34c0293..cf9b46f102 100644 --- a/packages/twenty-server/test/integration/graphql/suites/timeline-from-object-record.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/timeline-from-object-record.integration-spec.ts @@ -1,14 +1,13 @@ import gql from 'graphql-tag'; import { type DocumentNode } from 'graphql'; import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; -import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; const PAGE_SIZE = 50; -const PEOPLE_DISCOVERY_LIMIT = 100; const GET_TIMELINE_THREADS = gql` query GetTimelineThreadsFromObjectRecord( @@ -79,71 +78,52 @@ const requestTimeline = ( variables: { objectNameSingular, recordId, page: 1, pageSize: PAGE_SIZE }, }); -const getPeopleWithCompany = async (): Promise< - { id: string; companyId: string }[] -> => { +const TIMELINE_COMPANY_ID = '20202020-7e57-4000-8000-000000000001'; +const TIMELINE_PERSON_ID = '20202020-7e57-4000-8000-000000000002'; +const TIMELINE_MESSAGE_THREAD_ID = '20202020-7e57-4000-8000-000000000003'; +const TIMELINE_MESSAGE_ID = '20202020-7e57-4000-8000-000000000004'; +const TIMELINE_MESSAGE_PARTICIPANT_ID = '20202020-7e57-4000-8000-000000000005'; +const TIMELINE_CALENDAR_EVENT_ID = '20202020-7e57-4000-8000-000000000006'; +const TIMELINE_CALENDAR_EVENT_PARTICIPANT_ID = + '20202020-7e57-4000-8000-000000000007'; + +// Destroyed in afterAll in child-before-parent order to satisfy foreign keys. +const TIMELINE_FIXTURES: { objectMetadataSingularName: string; id: string }[] = + [ + { + objectMetadataSingularName: 'calendarEventParticipant', + id: TIMELINE_CALENDAR_EVENT_PARTICIPANT_ID, + }, + { + objectMetadataSingularName: 'calendarEvent', + id: TIMELINE_CALENDAR_EVENT_ID, + }, + { + objectMetadataSingularName: 'messageParticipant', + id: TIMELINE_MESSAGE_PARTICIPANT_ID, + }, + { objectMetadataSingularName: 'message', id: TIMELINE_MESSAGE_ID }, + { + objectMetadataSingularName: 'messageThread', + id: TIMELINE_MESSAGE_THREAD_ID, + }, + { objectMetadataSingularName: 'person', id: TIMELINE_PERSON_ID }, + { objectMetadataSingularName: 'company', id: TIMELINE_COMPANY_ID }, + ]; + +const createTimelineRecord = async ( + objectMetadataSingularName: string, + data: object, +) => { const response = await makeGraphqlAPIRequest( - findManyOperationFactory({ - objectMetadataSingularName: 'person', - objectMetadataPluralName: 'people', - gqlFields: 'id company { id }', - first: PEOPLE_DISCOVERY_LIMIT, + createOneOperationFactory({ + objectMetadataSingularName, + gqlFields: 'id', + data, }), ); expect(response.body.errors).toBeUndefined(); - - return ( - response.body.data.people.edges - // @ts-expect-error legacy noImplicitAny - .map((edge) => edge.node) - // @ts-expect-error legacy noImplicitAny - .filter((person) => person.company?.id) - // @ts-expect-error legacy noImplicitAny - .map((person) => ({ id: person.id, companyId: person.company.id })) - ); -}; - -const findPersonWithThreads = async ( - people: { id: string; companyId: string }[], -) => { - for (const person of people) { - const response = await requestTimeline( - GET_TIMELINE_THREADS, - 'person', - person.id, - ); - - if ( - response.body.data.getTimelineThreadsFromObjectRecord - .totalNumberOfThreads > 0 - ) { - return person; - } - } - - return undefined; -}; - -const findPersonWithCalendarEvents = async ( - people: { id: string; companyId: string }[], -) => { - for (const person of people) { - const response = await requestTimeline( - GET_TIMELINE_CALENDAR_EVENTS, - 'person', - person.id, - ); - - if ( - response.body.data.getTimelineCalendarEventsFromObjectRecord - .totalNumberOfCalendarEvents > 0 - ) { - return person; - } - } - - return undefined; }; describe('timeline from object record resolvers (integration)', () => { @@ -151,19 +131,76 @@ describe('timeline from object record resolvers (integration)', () => { let personWithEvents: { id: string; companyId: string }; beforeAll(async () => { - const people = await getPeopleWithCompany(); + await createTimelineRecord('company', { + id: TIMELINE_COMPANY_ID, + name: 'Timeline Source Company', + }); - const threadsSource = await findPersonWithThreads(people); - const eventsSource = await findPersonWithCalendarEvents(people); + await createTimelineRecord('person', { + id: TIMELINE_PERSON_ID, + name: { firstName: 'Timeline', lastName: 'Source' }, + companyId: TIMELINE_COMPANY_ID, + }); - if (!threadsSource || !eventsSource) { - throw new Error( - 'Expected the seeded workspace to contain a person with message threads and calendar events', + await createTimelineRecord('messageThread', { + id: TIMELINE_MESSAGE_THREAD_ID, + }); + + await createTimelineRecord('message', { + id: TIMELINE_MESSAGE_ID, + messageThreadId: TIMELINE_MESSAGE_THREAD_ID, + subject: 'Timeline source thread', + text: 'Timeline source message body', + receivedAt: new Date().toISOString(), + }); + + await createTimelineRecord('messageParticipant', { + id: TIMELINE_MESSAGE_PARTICIPANT_ID, + messageId: TIMELINE_MESSAGE_ID, + personId: TIMELINE_PERSON_ID, + role: 'FROM', + handle: 'timeline.source@example.com', + displayName: 'Timeline Source', + }); + + await createTimelineRecord('calendarEvent', { + id: TIMELINE_CALENDAR_EVENT_ID, + title: 'Timeline source event', + isFullDay: false, + startsAt: new Date().toISOString(), + endsAt: new Date().toISOString(), + }); + + await createTimelineRecord('calendarEventParticipant', { + id: TIMELINE_CALENDAR_EVENT_PARTICIPANT_ID, + calendarEventId: TIMELINE_CALENDAR_EVENT_ID, + personId: TIMELINE_PERSON_ID, + handle: 'timeline.source@example.com', + displayName: 'Timeline Source', + responseStatus: 'ACCEPTED', + isOrganizer: true, + }); + + personWithThreads = { + id: TIMELINE_PERSON_ID, + companyId: TIMELINE_COMPANY_ID, + }; + personWithEvents = { + id: TIMELINE_PERSON_ID, + companyId: TIMELINE_COMPANY_ID, + }; + }); + + afterAll(async () => { + for (const { objectMetadataSingularName, id } of TIMELINE_FIXTURES) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName, + gqlFields: 'id', + recordId: id, + }), ); } - - personWithThreads = threadsSource; - personWithEvents = eventsSource; }); it('should derive a company message timeline from its related people', async () => {