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:
+108
-71
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user