Skip to content

Commit 33f36bb

Browse files
committed
fix(sync): gate collection rebuilds on the info fields the converter reads
The canonical fingerprint now includes title, description, and contact name and email alongside paths and components, matching what the collection name and description read from info. Adds scripts/sync.test.ts covering the gate, including a case where only the description changes. CI and the pre-push hook now run bun test alongside typecheck, lint, and the dry run sync.
1 parent 1f36072 commit 33f36bb

5 files changed

Lines changed: 143 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ jobs:
2424
- name: Typecheck
2525
run: bun typecheck
2626

27+
- name: Test
28+
run: bun test
29+
2730
- name: Validate discovery and conversion (no Postman writes)
2831
run: bun run sync --dry-run

lefthook.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ pre-push:
2020
sync-dry-run:
2121
run: bun run sync --dry-run
2222
priority: 3
23+
test:
24+
run: bun test
25+
priority: 4

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"scripts": {
88
"sync": "bun run scripts/sync.ts",
99
"check": "biome check . --fix",
10-
"typecheck": "tsc --noEmit"
10+
"typecheck": "tsc --noEmit",
11+
"test": "bun test"
1112
},
1213
"license": "MIT",
1314
"repository": {

scripts/sync.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { canonical, fingerprint } from './sync';
3+
4+
/** Minimal spec shape the fingerprint reads from. Extra `info` fields (version, license, contact.url) are included where a case needs them. */
5+
function spec(overrides: {
6+
title?: string;
7+
description?: string;
8+
contact?: { name?: string; email?: string; url?: string };
9+
version?: string;
10+
paths?: Record<string, unknown>;
11+
components?: Record<string, unknown>;
12+
}) {
13+
return {
14+
info: {
15+
title: overrides.title ?? 'Forecast API',
16+
description:
17+
overrides.description ?? 'Merges transits into one timeline.',
18+
contact: overrides.contact,
19+
version: overrides.version,
20+
},
21+
paths: overrides.paths ?? { '/forecast': { get: {} } },
22+
components: overrides.components ?? { schemas: {} },
23+
};
24+
}
25+
26+
describe('fingerprint', () => {
27+
test('is stable when nothing changes', () => {
28+
expect(fingerprint(spec({}))).toBe(fingerprint(spec({})));
29+
});
30+
31+
test('changes when only info.description changes and paths/components are identical', () => {
32+
const before = spec({ description: '14 domains, 209+ endpoints.' });
33+
const after = spec({ description: '18 domains, 258+ endpoints.' });
34+
expect(fingerprint(before)).not.toBe(fingerprint(after));
35+
});
36+
37+
test('changes when only info.title changes', () => {
38+
const before = spec({ title: 'Forecast API' });
39+
const after = spec({ title: 'Forecast API v2' });
40+
expect(fingerprint(before)).not.toBe(fingerprint(after));
41+
});
42+
43+
test('changes when only contact.name or contact.email changes', () => {
44+
const before = spec({ contact: { name: 'RoxyAPI Support' } });
45+
const after = spec({ contact: { name: 'Someone Else' } });
46+
expect(fingerprint(before)).not.toBe(fingerprint(after));
47+
48+
const beforeEmail = spec({ contact: { name: 'RoxyAPI Support' } });
49+
const afterEmail = spec({
50+
contact: { name: 'RoxyAPI Support', email: 'support@roxyapi.com' },
51+
});
52+
expect(fingerprint(beforeEmail)).not.toBe(fingerprint(afterEmail));
53+
});
54+
55+
test('does not change for info fields the collection never surfaces (version, contact.url)', () => {
56+
const before = spec({
57+
version: '1.0.0',
58+
contact: { url: 'https://roxyapi.com/contact' },
59+
});
60+
const after = spec({
61+
version: '2.0.0',
62+
contact: { url: 'https://roxyapi.com/other' },
63+
});
64+
expect(fingerprint(before)).toBe(fingerprint(after));
65+
});
66+
67+
test('changes when paths or components change', () => {
68+
const before = spec({ paths: { '/forecast': { get: {} } } });
69+
const after = spec({
70+
paths: { '/forecast': { get: {} }, '/forecast/digest': { get: {} } },
71+
});
72+
expect(fingerprint(before)).not.toBe(fingerprint(after));
73+
74+
const beforeSchema = spec({ components: { schemas: { Forecast: {} } } });
75+
const afterSchema = spec({
76+
components: { schemas: { Forecast: {}, Digest: {} } },
77+
});
78+
expect(fingerprint(beforeSchema)).not.toBe(fingerprint(afterSchema));
79+
});
80+
81+
test('is insensitive to key order, matching the canonical semantic diff', () => {
82+
const a = {
83+
info: { title: 'X', description: 'd' },
84+
paths: { '/a': {}, '/b': {} },
85+
components: { schemas: {} },
86+
};
87+
const b = {
88+
info: { description: 'd', title: 'X' },
89+
components: { schemas: {} },
90+
paths: { '/b': {}, '/a': {} },
91+
};
92+
expect(fingerprint(a)).toBe(fingerprint(b));
93+
});
94+
});
95+
96+
describe('canonical', () => {
97+
test('sorts object keys recursively so JSON.stringify output is order independent', () => {
98+
const a = canonical({ b: 1, a: { d: 2, c: 3 } });
99+
const b = canonical({ a: { c: 3, d: 2 }, b: 1 });
100+
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
101+
});
102+
});

scripts/sync.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*
77
* Pipeline per run:
88
* 1. Discover domains: read the combined spec, take every distinct first path segment, keep the ones that serve their own per-domain spec (a 200 at `/api/v2/{slug}/openapi.json`). App utility routes (languages, usage) return 401 and drop out, so no exclusion list is needed.
9-
* 2. Change-gate: compare the canonical `{ paths, components }` of each live per-domain spec against the vendored baseline in `specs/{slug}.json`. Unchanged and already-published domains are skipped (zero Postman writes).
9+
* 2. Change-gate: compare the canonical `{ paths, components, info }` of each live per-domain spec against the vendored baseline in `specs/{slug}.json`, where `info` is only the slice openapi-to-postmanv2 actually reads (title, description, contact name/email; see `collectionInfo`). Unchanged and already-published domains are skipped (zero Postman writes).
1010
* 3. Build: rewrite the spec server to the absolute domain base so request URLs resolve correctly, convert with openapi-to-postmanv2, stamp a `roxySlug` provenance variable plus an empty `apiKey` so a forked collection is self-contained.
1111
* 4. Publish: resolve the existing collection UID (cache, then workspace name match), then PUT it; create + capture the UID when it does not exist yet.
1212
* 5. Persist: write `specs/{slug}.json` (next run's diff baseline), `collections/{slug}.json` (browsable artifact), and the auto-written `collections.json` UID cache. The workflow commits these back.
@@ -38,7 +38,13 @@ const COLLECTIONS_DIR = join(ROOT, 'collections');
3838
const CACHE_PATH = join(ROOT, 'collections.json');
3939

4040
interface OpenAPISpec {
41-
info: { title: string };
41+
info: {
42+
title: string;
43+
description?: string;
44+
version?: string;
45+
contact?: { name?: string; email?: string; url?: string };
46+
license?: unknown;
47+
};
4248
servers?: unknown[];
4349
paths: Record<string, unknown>;
4450
components?: Record<string, unknown>;
@@ -71,14 +77,14 @@ const PRUNE = args.has('--prune');
7177
const POSTMAN_API_KEY = process.env.POSTMAN_API_KEY;
7278
const DRY_RUN = args.has('--dry-run') || !POSTMAN_API_KEY;
7379

74-
if (!POSTMAN_API_KEY && !args.has('--dry-run')) {
80+
if (import.meta.main && !POSTMAN_API_KEY && !args.has('--dry-run')) {
7581
console.error(
7682
'POSTMAN_API_KEY is not set. Export it to publish, or pass --dry-run to validate without writing.',
7783
);
7884
process.exit(1);
7985
}
8086

81-
function canonical(value: unknown): unknown {
87+
export function canonical(value: unknown): unknown {
8288
if (Array.isArray(value)) return value.map(canonical);
8389
if (value && typeof value === 'object') {
8490
const source = value as Record<string, unknown>;
@@ -90,10 +96,29 @@ function canonical(value: unknown): unknown {
9096
return value;
9197
}
9298

99+
/**
100+
* The only `info` fields openapi-to-postmanv2 surfaces in the published collection: `title`
101+
* becomes the collection name (`getCollectionName`), `description` plus `contact.name` /
102+
* `contact.email` become its description (`getCollectionDescription`). `version`, `license`, and
103+
* `contact.url` never reach the output, so they stay out of the fingerprint on purpose.
104+
*/
105+
export function collectionInfo(spec: OpenAPISpec): Record<string, unknown> {
106+
return {
107+
title: spec.info.title,
108+
description: spec.info.description ?? '',
109+
contactName: spec.info.contact?.name ?? null,
110+
contactEmail: spec.info.contact?.email ?? null,
111+
};
112+
}
113+
93114
/** Stable fingerprint of the parts of a spec that affect the generated collection. */
94-
function fingerprint(spec: OpenAPISpec): string {
115+
export function fingerprint(spec: OpenAPISpec): string {
95116
return JSON.stringify(
96-
canonical({ paths: spec.paths, components: spec.components ?? null }),
117+
canonical({
118+
paths: spec.paths,
119+
components: spec.components ?? null,
120+
info: collectionInfo(spec),
121+
}),
97122
);
98123
}
99124

@@ -320,4 +345,5 @@ async function main(): Promise<void> {
320345
if (removed.length) console.log(` pruned: ${removed.join(', ')}`);
321346
}
322347

323-
await main();
348+
// Guarded so tests can import fingerprint/canonical/collectionInfo without running the pipeline.
349+
if (import.meta.main) await main();

0 commit comments

Comments
 (0)