test(server): make timeline integration test self-seed its data (#21896)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21896?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-20 14:29:28 +02:00
committed by GitHub
parent b8ea742a88
commit 7eafbd91c6
@@ -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 () => {