Skip to content

Commit 892dd9b

Browse files
committed
test: harden v2 empty-check and add mirror gate unit tests
Two follow-ups from the v2-rc2 test-effectiveness review: 1. checkDatabaseEmpty (v2 live-api helper) previously scoped its probe to orgUid=me and silently swallowed any non-200/throw, so a table it could not actually inspect (e.g. when no home org is configured and 'me' fails to resolve) was treated as empty and slipped past the guard. It now fails loudly on any un-inspectable table, and runs an advisory unfiltered probe that surfaces synced non-home rows in the logs instead of ignoring them outright. 2. Add direct unit coverage for the shared mirror in-sync gate. The V1/V2 backfill-dedupe specs cannot reach the "non-zero count with null MAX(updatedAt)" defensive branch because every mirror model declares timestamps: true (NOT NULL updated_at). Exercise that branch and the matchingUpdatedAtAttr helper against lightweight stub models.
1 parent 9c440f0 commit 892dd9b

2 files changed

Lines changed: 264 additions & 10 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { expect } from 'chai';
2+
3+
import {
4+
isMirrorInSync,
5+
matchingUpdatedAtAttr,
6+
UPDATED_AT_ATTR_CANDIDATES,
7+
} from '../../src/database/mirror-sync-gate.js';
8+
9+
/**
10+
* Direct unit tests for the shared mirror in-sync gate.
11+
*
12+
* The V1/V2 backfill-dedupe integration specs drive isMirrorInSync through
13+
* real Sequelize models, but they cannot reach the "non-zero count with a
14+
* null MAX(updatedAt)" defensive branch: every CADT mirror model declares
15+
* `timestamps: true`, so SQLite rejects any write that would leave
16+
* updated_at null. That branch is therefore exercised here against
17+
* lightweight stub models, where we control exactly what count()/max()
18+
* return without fighting a NOT NULL constraint.
19+
*
20+
* Stub model: only count() and max() are used by isMirrorInSync, so a plain
21+
* object with those two async methods is a faithful stand-in for a Sequelize
22+
* model as far as the gate is concerned.
23+
*/
24+
const makeModel = ({ count, max }) => ({
25+
count: async () => count,
26+
max: async () => max,
27+
});
28+
29+
// Silent logger - the gate only logs at debug level; we don't assert on it.
30+
const silentLogger = { debug: () => {} };
31+
32+
describe('Mirror in-sync gate (isMirrorInSync) — unit', function () {
33+
const ATTR = 'updatedAt';
34+
35+
it('skips when both sides are empty (count 0 on both)', async function () {
36+
const source = makeModel({ count: 0, max: null });
37+
const mirror = makeModel({ count: 0, max: null });
38+
39+
const result = await isMirrorInSync(
40+
source,
41+
mirror,
42+
'projects',
43+
ATTR,
44+
silentLogger,
45+
);
46+
47+
expect(result).to.equal(true);
48+
});
49+
50+
it('falls through when counts mismatch', async function () {
51+
const source = makeModel({ count: 5, max: '2026-01-01 00:00:00' });
52+
const mirror = makeModel({ count: 4, max: '2026-01-01 00:00:00' });
53+
54+
const result = await isMirrorInSync(
55+
source,
56+
mirror,
57+
'projects',
58+
ATTR,
59+
silentLogger,
60+
);
61+
62+
expect(result).to.equal(false);
63+
});
64+
65+
it('skips when counts match and mirror max(updatedAt) >= source', async function () {
66+
const source = makeModel({ count: 3, max: '2026-01-01 00:00:00' });
67+
const mirror = makeModel({ count: 3, max: '2026-01-02 00:00:00' });
68+
69+
const result = await isMirrorInSync(
70+
source,
71+
mirror,
72+
'projects',
73+
ATTR,
74+
silentLogger,
75+
);
76+
77+
expect(result).to.equal(true);
78+
});
79+
80+
it('falls through when mirror max(updatedAt) is older than source', async function () {
81+
const source = makeModel({ count: 3, max: '2026-02-01 00:00:00' });
82+
const mirror = makeModel({ count: 3, max: '2026-01-01 00:00:00' });
83+
84+
const result = await isMirrorInSync(
85+
source,
86+
mirror,
87+
'projects',
88+
ATTR,
89+
silentLogger,
90+
);
91+
92+
expect(result).to.equal(false);
93+
});
94+
95+
// The documented gap: non-zero count but a null MAX(updatedAt). The gate
96+
// must NOT misclassify this as "empty / in sync" - it can't compare
97+
// freshness, so it must fall through to the full sync.
98+
it('falls through when source max(updatedAt) is null with rows present', async function () {
99+
const source = makeModel({ count: 2, max: null });
100+
const mirror = makeModel({ count: 2, max: '2026-01-01 00:00:00' });
101+
102+
const result = await isMirrorInSync(
103+
source,
104+
mirror,
105+
'projects',
106+
ATTR,
107+
silentLogger,
108+
);
109+
110+
expect(result).to.equal(false);
111+
});
112+
113+
it('falls through when mirror max(updatedAt) is null with rows present', async function () {
114+
const source = makeModel({ count: 2, max: '2026-01-01 00:00:00' });
115+
const mirror = makeModel({ count: 2, max: null });
116+
117+
const result = await isMirrorInSync(
118+
source,
119+
mirror,
120+
'projects',
121+
ATTR,
122+
silentLogger,
123+
);
124+
125+
expect(result).to.equal(false);
126+
});
127+
128+
it('falls through when max(updatedAt) is non-parseable with rows present', async function () {
129+
const source = makeModel({ count: 1, max: 'not-a-date' });
130+
const mirror = makeModel({ count: 1, max: 'not-a-date' });
131+
132+
const result = await isMirrorInSync(
133+
source,
134+
mirror,
135+
'projects',
136+
ATTR,
137+
silentLogger,
138+
);
139+
140+
expect(result).to.equal(false);
141+
});
142+
143+
it('falls through (does not throw) when a probe query errors', async function () {
144+
const source = {
145+
count: async () => {
146+
throw new Error('boom');
147+
},
148+
max: async () => null,
149+
};
150+
const mirror = makeModel({ count: 0, max: null });
151+
152+
const result = await isMirrorInSync(
153+
source,
154+
mirror,
155+
'projects',
156+
ATTR,
157+
silentLogger,
158+
);
159+
160+
expect(result).to.equal(false);
161+
});
162+
});
163+
164+
describe('matchingUpdatedAtAttr — unit', function () {
165+
const withAttrs = (...names) => ({
166+
rawAttributes: Object.fromEntries(names.map((n) => [n, {}])),
167+
});
168+
169+
it('returns the shared camelCase attribute (V1 convention)', function () {
170+
const attr = matchingUpdatedAtAttr(
171+
withAttrs('updatedAt'),
172+
withAttrs('updatedAt'),
173+
);
174+
expect(attr).to.equal('updatedAt');
175+
});
176+
177+
it('returns the shared snake_case attribute (V2 convention)', function () {
178+
const attr = matchingUpdatedAtAttr(
179+
withAttrs('updated_at'),
180+
withAttrs('updated_at'),
181+
);
182+
expect(attr).to.equal('updated_at');
183+
});
184+
185+
it('returns null when source and mirror disagree on the attribute name', function () {
186+
const attr = matchingUpdatedAtAttr(
187+
withAttrs('updatedAt'),
188+
withAttrs('updated_at'),
189+
);
190+
expect(attr).to.equal(null);
191+
});
192+
193+
it('only considers the known candidate attribute names', function () {
194+
expect(UPDATED_AT_ATTR_CANDIDATES).to.deep.equal(['updatedAt', 'updated_at']);
195+
});
196+
});

tests/v2/live-api/helpers/live-api-helpers.js

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -498,28 +498,86 @@ export const checkDatabaseEmpty = async (request) => {
498498
];
499499

500500
const nonEmptyTables = [];
501+
const syncedNonHomeTables = [];
502+
const probeFailures = [];
501503

502504
for (const table of dataTables) {
505+
// Home-scoped probe: this is the actual cleanliness gate. Scope to
506+
// home-org-owned records only because, with governance sync enabled, the
507+
// node legitimately syncs in data owned by other organizations; that data
508+
// is not leftover test state and must not fail the empty check.
509+
let homeResponse;
503510
try {
504-
// Scope to home-org-owned records only. When governance sync is enabled,
505-
// the node legitimately syncs in data owned by other organizations; that
506-
// data is not leftover test state and must not fail the empty check.
507-
const response = await request.get(`/v2/${table}`).query({ page: 1, limit: 1000, orgUid: 'me' });
508-
const data = response.body?.data || [];
509-
510-
if (response.status === 200 && Array.isArray(data) && data.length > 0) {
511-
nonEmptyTables.push({ table, count: data.length });
512-
}
511+
homeResponse = await request
512+
.get(`/v2/${table}`)
513+
.query({ page: 1, limit: 1000, orgUid: 'me' });
513514
} catch (error) {
514-
// Table might not exist or endpoint might not be available, skip
515+
probeFailures.push({ table, reason: error.message });
516+
continue;
517+
}
518+
519+
// A non-200 means the gate could not actually inspect this table - e.g.
520+
// 'me' failed to resolve because no home organization is configured. Treat
521+
// that as a hard failure rather than silently assuming the table is empty:
522+
// a silently-skipped table would let leftover state slip past the guard.
523+
if (homeResponse.status !== 200) {
524+
probeFailures.push({
525+
table,
526+
reason: `home-scoped GET returned status ${homeResponse.status}`,
527+
});
528+
continue;
529+
}
530+
531+
const homeData = Array.isArray(homeResponse.body?.data)
532+
? homeResponse.body.data
533+
: [];
534+
if (homeData.length > 0) {
535+
nonEmptyTables.push({ table, count: homeData.length });
536+
}
537+
538+
// Unfiltered probe: advisory only. Surfaces synced non-home data so a
539+
// genuine leak of foreign rows is at least visible in the logs, without
540+
// failing the run when governance sync legitimately populated it.
541+
try {
542+
const allResponse = await request
543+
.get(`/v2/${table}`)
544+
.query({ page: 1, limit: 1000 });
545+
if (allResponse.status === 200) {
546+
const allData = Array.isArray(allResponse.body?.data)
547+
? allResponse.body.data
548+
: [];
549+
const nonHomeCount = allData.length - homeData.length;
550+
if (nonHomeCount > 0) {
551+
syncedNonHomeTables.push({ table, count: nonHomeCount });
552+
}
553+
}
554+
} catch {
555+
// Advisory probe only - never fail the run on it.
515556
}
516557
}
517558

559+
if (probeFailures.length > 0) {
560+
const detail = probeFailures
561+
.map(({ table, reason }) => ` - ${table}: ${reason}`)
562+
.join('\n');
563+
throw new Error(
564+
`Database empty-check could not inspect every table (is a home organization configured?):\n${detail}`,
565+
);
566+
}
567+
518568
if (nonEmptyTables.length > 0) {
519569
const errorMsg = `Database is not empty. Found home-org data in:\n${nonEmptyTables.map(({ table, count }) => ` - ${table}: ${count} record(s)`).join('\n')}\nPlease clear the database before running tests.`;
520570
throw new Error(errorMsg);
521571
}
522572

573+
if (syncedNonHomeTables.length > 0) {
574+
console.log(
575+
`⚠ Ignoring synced non-home data (not leftover test state):\n${syncedNonHomeTables
576+
.map(({ table, count }) => ` - ${table}: ${count} record(s)`)
577+
.join('\n')}`,
578+
);
579+
}
580+
523581
console.log('✓ Database is empty of home-org data (synced non-home data ignored)');
524582
};
525583

0 commit comments

Comments
 (0)