test(v0.18.2.fork.1): manifest edge cases — malformed jsonb + concurrent same-slug
Closes Issue #9 from /plan-eng-review (user decision A: 加三個都). Cache TTL hit/miss/invalidation already covered in test/longest-prefix-match.test.ts. This file adds the two remaining edge-case scenarios: - Malformed jsonb safe-skip: slug_prefix_rules = "not_an_array" string, mixed-type array entries, and 'null'::jsonb config all handled gracefully — bad rows skip, valid rows continue matching. - Concurrent put_page on same slug across two sources: both rows persist, composite UNIQUE (source_id, slug) does its job. Note: manifest-jsonb-pglite.test.ts (originally planned in design Phase 5 for engine parity) is dropped from scope. The implementation parses jsonb in TypeScript via JSON.parse on the SELECT result, not via SQL jsonb_array_elements / ->>operators, so PGLite vs Postgres jsonb-operator parity is not exercised by manifest routing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
127
test/manifest-edge-cases.test.ts
Normal file
127
test/manifest-edge-cases.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* v0.18.2.fork.1 — manifest edge-cases (per /plan-eng-review Issue #9).
|
||||
*
|
||||
* Verifies the resolver gracefully handles cases that could only show up
|
||||
* once real production data shape diverges from the happy path:
|
||||
*
|
||||
* - Malformed jsonb in sources.config (manually edited, partial corruption)
|
||||
* → safe-skip the bad row, continue evaluating other sources
|
||||
* - slug_prefix_rules: 'not_an_array' (string instead of string[])
|
||||
* → safe-skip
|
||||
* - slug_prefix_rules contains a non-string entry (mixed array)
|
||||
* → skip the non-string entries, keep valid ones
|
||||
* - Concurrent put_page on same slug across two distinct sources
|
||||
* → both rows succeed (composite UNIQUE allows; no race on schema-level)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import {
|
||||
resolveBySlugPrefix,
|
||||
__invalidateSlugPrefixCache,
|
||||
} from '../src/core/source-resolver.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ type: 'pglite' } as never);
|
||||
await engine.initSchema();
|
||||
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, config) VALUES
|
||||
('valid-source', 'valid-source', '{"federated": true, "slug_prefix_rules": ["valid/"]}'::jsonb),
|
||||
('side-a', 'side-a', '{"federated": true}'::jsonb),
|
||||
('side-b', 'side-b', '{"federated": true}'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
__invalidateSlugPrefixCache();
|
||||
});
|
||||
|
||||
describe('Malformed jsonb safe-skip', () => {
|
||||
test('slug_prefix_rules = "not_an_array" (string) → resolver ignores that row, valid sources still match', async () => {
|
||||
// Manually corrupt one source's config without going through CLI.
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, config) VALUES
|
||||
('bad-string', 'bad-string', '{"slug_prefix_rules": "not_an_array"}'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`,
|
||||
);
|
||||
__invalidateSlugPrefixCache();
|
||||
// Valid source should still match its own prefix.
|
||||
const r1 = await resolveBySlugPrefix(engine, 'valid/page');
|
||||
expect(r1).toBe('valid-source');
|
||||
// Bad source claims nothing — no slug routes there.
|
||||
const r2 = await resolveBySlugPrefix(engine, 'not-an-array/x');
|
||||
expect(r2).toBeNull();
|
||||
});
|
||||
|
||||
test('slug_prefix_rules contains mixed-type entries → string entries kept, non-strings skipped', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, config) VALUES
|
||||
('mixed-types', 'mixed-types',
|
||||
'{"slug_prefix_rules": ["good-prefix/", 42, null, "another-good/"]}'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`,
|
||||
);
|
||||
__invalidateSlugPrefixCache();
|
||||
const r1 = await resolveBySlugPrefix(engine, 'good-prefix/x');
|
||||
expect(r1).toBe('mixed-types');
|
||||
const r2 = await resolveBySlugPrefix(engine, 'another-good/y');
|
||||
expect(r2).toBe('mixed-types');
|
||||
});
|
||||
|
||||
test('config = null jsonb → safe-skip (NOT NULL constraint prevents in practice, but defensive)', async () => {
|
||||
// PGLite's NOT NULL on sources.config will reject the literal NULL,
|
||||
// so we test the edge by writing 'null' (jsonb null literal) which
|
||||
// is allowed.
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, config) VALUES
|
||||
('json-null', 'json-null', 'null'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`,
|
||||
);
|
||||
__invalidateSlugPrefixCache();
|
||||
// Resolver should skip cleanly — no slug routes to json-null.
|
||||
const r = await resolveBySlugPrefix(engine, 'anything/x');
|
||||
expect(r).toBeNull();
|
||||
// And the valid source still works.
|
||||
const r2 = await resolveBySlugPrefix(engine, 'valid/page');
|
||||
expect(r2).toBe('valid-source');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent put_page same slug across sources', () => {
|
||||
test('same slug written to two different sources → both rows persist (composite UNIQUE)', async () => {
|
||||
// Run both writes "concurrently" via Promise.all. PGLite is
|
||||
// single-process so they serialize at the engine layer, but the SQL
|
||||
// semantics still validate: composite UNIQUE on (source_id, slug)
|
||||
// means both INSERTs fit without conflicting.
|
||||
await Promise.all([
|
||||
engine.putPage('shared-slug', {
|
||||
type: 'note',
|
||||
title: 'Side A',
|
||||
compiled_truth: 'A side',
|
||||
source_id: 'side-a',
|
||||
}),
|
||||
engine.putPage('shared-slug', {
|
||||
type: 'note',
|
||||
title: 'Side B',
|
||||
compiled_truth: 'B side',
|
||||
source_id: 'side-b',
|
||||
}),
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ source_id: string; title: string }>(
|
||||
`SELECT source_id, title FROM pages WHERE slug = 'shared-slug' ORDER BY source_id`,
|
||||
);
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows[0].source_id).toBe('side-a');
|
||||
expect(rows[0].title).toBe('Side A');
|
||||
expect(rows[1].source_id).toBe('side-b');
|
||||
expect(rows[1].title).toBe('Side B');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user