377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
/**
|
|
* Stage 4 — data contracts tests.
|
|
* Covers: valid package, missing/malformed, versions, timestamps, ranges,
|
|
* stable IDs, optional sections, forward compat, incompatible, emergency, separation, metadata, readiness inputs.
|
|
* Trace: SPIKE-04, IMPLEMENTATION-CONTRACT.md §15, §20, §24, §40
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
validateManifest,
|
|
validateSignature,
|
|
validateScheduleEvent,
|
|
validateMapPoi,
|
|
validatePackage,
|
|
isCompatible,
|
|
validateEdition,
|
|
validatePackageVersion,
|
|
validateEmergencyFloor,
|
|
} from "../../src/data/festival-package/validation.js";
|
|
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
|
|
import { isStableId } from "../../src/data/festival-package/validation.js";
|
|
|
|
function validManifest(): FestivalManifest {
|
|
return {
|
|
format: "lumen.package/1",
|
|
edition: "lumen-2026",
|
|
packageVersion: 7,
|
|
schemaVersion: 1,
|
|
generatedAt: "2026-08-28T14:02:11Z",
|
|
festival: {
|
|
name: "Lumen Festival 2026",
|
|
timezone: "America/Chicago",
|
|
startUtc: Date.UTC(2026, 8, 10),
|
|
endUtc: Date.UTC(2026, 8, 13),
|
|
},
|
|
appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null },
|
|
sections: {
|
|
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 41233, required: true },
|
|
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 412201, required: true },
|
|
map: { file: "map.json", sha256: "c".repeat(64), bytes: 38122, required: true },
|
|
info: { file: "info.json", sha256: "d".repeat(64), bytes: 96710, required: true },
|
|
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 7111, required: true },
|
|
},
|
|
counts: { events: 312, pois: 87, assets: 14 },
|
|
limits: { totalBytes: 31240012 },
|
|
};
|
|
}
|
|
|
|
function validSignature() {
|
|
return {
|
|
algorithm: "ed25519" as const,
|
|
over: "sha256(manifest.json exact bytes)",
|
|
manifestSha256: "f".repeat(64),
|
|
publicKeyFingerprint: "sha256:abc",
|
|
signature: "base64==",
|
|
};
|
|
}
|
|
|
|
describe("Festival Data Package — valid package", () => {
|
|
it("valid manifest passes", () => {
|
|
expect(validateManifest(validManifest()).ok).toBe(true);
|
|
});
|
|
it("valid signature passes", () => {
|
|
expect(validateSignature(validSignature()).ok).toBe(true);
|
|
});
|
|
it("valid package (manifest+signature+emergency) passes", () => {
|
|
expect(
|
|
validatePackage({
|
|
manifest: validManifest(),
|
|
signature: validSignature(),
|
|
emergency: {
|
|
section: "emergency",
|
|
emergencySchemaVersion: 1,
|
|
contentVersion: 3,
|
|
updatedAt: "2026-08-27T09:00:00Z",
|
|
services: {
|
|
emergencyNumber: "911",
|
|
security: { phone: "+1-555" },
|
|
firstAid: { location: "A" },
|
|
},
|
|
locations: { musterPoints: [], exits: [], aeds: [] },
|
|
address: { lines: ["x"], coordinates: { lat: 0, lon: 0 } },
|
|
procedures: [],
|
|
},
|
|
}).ok,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("missing required data", () => {
|
|
it("missing sections fails", () => {
|
|
const m = { ...validManifest(), sections: undefined as unknown as Record<string, unknown> };
|
|
expect(validateManifest(m).ok).toBe(false);
|
|
});
|
|
it("missing festival timezone fails", () => {
|
|
const m = validManifest();
|
|
const bad = { ...m, festival: { ...m.festival, timezone: "" } };
|
|
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
|
|
});
|
|
it("missing signature field fails", () => {
|
|
const sig = { ...validSignature(), signature: "" };
|
|
expect(validateSignature(sig).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("malformed IDs", () => {
|
|
it("event id with spaces fails", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "bad id",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 1000,
|
|
endUtc: 2000,
|
|
dayKey: "2026-09-11",
|
|
tags: [],
|
|
status: "scheduled",
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
it("valid event id passes", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "evt-0113",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 1000,
|
|
endUtc: 2000,
|
|
dayKey: "2026-09-11",
|
|
tags: [],
|
|
status: "scheduled",
|
|
}).ok,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("malformed versions", () => {
|
|
it("packageVersion 0 fails, 1 passes", () => {
|
|
expect(validatePackageVersion(0).ok).toBe(false);
|
|
expect(validatePackageVersion(1).ok).toBe(true);
|
|
});
|
|
it("edition empty fails", () => {
|
|
expect(validateEdition("").ok).toBe(false);
|
|
});
|
|
it("schemaVersion float fails", () => {
|
|
expect(
|
|
validatePackage({
|
|
manifest: { ...validManifest(), schemaVersion: 1.5 },
|
|
signature: validSignature(),
|
|
emergency: { section: "emergency", emergencySchemaVersion: 1 },
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("invalid timestamps / ranges", () => {
|
|
it("end before start fails", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "evt-1",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 2000,
|
|
endUtc: 1000,
|
|
dayKey: "2026-09-11",
|
|
tags: [],
|
|
status: "scheduled",
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
it("zero-length fails", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "evt-1",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 1000,
|
|
endUtc: 1000,
|
|
dayKey: "2026-09-11",
|
|
tags: [],
|
|
status: "scheduled",
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
it("festival window end before start fails", () => {
|
|
const bad = {
|
|
...validManifest(),
|
|
festival: { ...validManifest().festival, startUtc: 2000, endUtc: 1000 },
|
|
};
|
|
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
|
|
});
|
|
it("event longer than 24h fails", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "evt-1",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 0,
|
|
endUtc: 25 * 3600 * 1000,
|
|
dayKey: "2026-09-11",
|
|
tags: [],
|
|
status: "scheduled",
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
it("dayKey malformed fails", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "evt-1",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 1000,
|
|
endUtc: 2000,
|
|
dayKey: "11-09-2026",
|
|
tags: [],
|
|
status: "scheduled",
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("map POI validation", () => {
|
|
it("x/y 0..1 passes, out of range fails", () => {
|
|
expect(validateMapPoi({ id: "p", name: "n", category: "aed", x: 0.5, y: 0.5 }).ok).toBe(true);
|
|
expect(validateMapPoi({ id: "p", name: "n", category: "aed", x: 1.5, y: 0.5 }).ok).toBe(false);
|
|
});
|
|
it("lat/lng null allowed (GPS optional per I-7)", () => {
|
|
expect(
|
|
validateMapPoi({ id: "p", name: "n", category: "stage", x: 0, y: 0, lat: null, lng: null })
|
|
.ok,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("stable ID relationships (favorites survive updates)", () => {
|
|
it("stable id check", () => {
|
|
const prev = new Set(["evt-0113", "evt-0114"]);
|
|
expect(isStableId("evt-0113", prev)).toBe(true);
|
|
expect(isStableId("evt-9999", prev)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("optional sections + forward compat", () => {
|
|
it("optional section may be missing without failing manifest (required flag)", () => {
|
|
// In Stage 4 we require all 5 for now; but forward compat means unknown sections allowed
|
|
const m = validManifest() as unknown as Record<string, unknown>;
|
|
// Add unknown optional section — should not break manifest validation (allowed forward compat)
|
|
(m.sections as Record<string, unknown>).announcements = {
|
|
file: "announcements.json",
|
|
sha256: "a".repeat(64),
|
|
bytes: 100,
|
|
required: false,
|
|
};
|
|
expect(validateManifest(m).ok).toBe(true);
|
|
});
|
|
it("unknown fields on event are allowed forward-compat", () => {
|
|
expect(
|
|
validateScheduleEvent({
|
|
id: "evt-1",
|
|
title: "x",
|
|
stageId: "s",
|
|
artistIds: [],
|
|
startUtc: 1000,
|
|
endUtc: 2000,
|
|
dayKey: "2026-09-11",
|
|
tags: [],
|
|
status: "scheduled",
|
|
// unknown future field
|
|
futureField: "ok",
|
|
}).ok,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("incompatible package versions", () => {
|
|
it("schemaVersion outside shell range is incompatible", () => {
|
|
const m = validManifest();
|
|
expect(isCompatible({ ...m, schemaVersion: 99 }, [1, 2], "1.0.0")).toBe(false);
|
|
});
|
|
it("app version older than min is incompatible", () => {
|
|
const m = validManifest();
|
|
expect(isCompatible(m, [1], "0.9.0")).toBe(false);
|
|
});
|
|
it("compatible within range passes", () => {
|
|
const m = validManifest();
|
|
expect(isCompatible(m, [1], "1.0.0")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("emergency data representation (SPIKE-06)", () => {
|
|
it("floor shape passes", () => {
|
|
expect(
|
|
validateEmergencyFloor({
|
|
floor: true,
|
|
floorVersion: "1.0.0+2026.08.20",
|
|
emergencySchemaVersion: 1,
|
|
generatedAt: "2026-08-20T12:00:00Z",
|
|
sourceContentVersion: 5,
|
|
services: {},
|
|
address: { lines: ["x"], coordinates: { lat: 0, lon: 0 } },
|
|
musterPoints: [],
|
|
exits: [],
|
|
aedSummary: "x",
|
|
procedures: [],
|
|
}).ok,
|
|
).toBe(true);
|
|
});
|
|
it("floor missing version fails", () => {
|
|
expect(validateEmergencyFloor({ floor: true }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("separation of user data from festival data (IMP contract §CRITICAL)", () => {
|
|
it("festival package does not contain favorites", () => {
|
|
const pkg = validManifest() as unknown as Record<string, unknown>;
|
|
expect(pkg.favorites).toBeUndefined();
|
|
expect(pkg.favorites).not.toBeDefined();
|
|
});
|
|
it("user store is separate type", async () => {
|
|
const mod = await import("../../src/data/user/types.js");
|
|
expect(mod).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("dataset metadata", () => {
|
|
it("counts and limits present", () => {
|
|
const m = validManifest();
|
|
expect(m.counts.events).toBe(312);
|
|
expect(m.limits.totalBytes).toBe(31240012);
|
|
});
|
|
it("totalBytes >50MB hard ceiling fails", () => {
|
|
const bad = { ...validManifest(), limits: { totalBytes: 60 * 1024 * 1024 } };
|
|
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("readiness inputs (time-independent per SPIKE-05)", () => {
|
|
it("readiness is data-driven, not clock", async () => {
|
|
const { evaluateReadiness } = await import("../../src/domain/readiness/logic.js");
|
|
const allOk = {
|
|
shellValid: true,
|
|
datasetPresent: true,
|
|
authenticity: "verified" as const,
|
|
integrity: "ok" as const,
|
|
schemaCompatible: true,
|
|
requiredSectionsOk: true,
|
|
requiredAssetsOk: true,
|
|
emergencyFloorOk: true,
|
|
missing: [] as readonly string[],
|
|
hadVerifiedDataset: true,
|
|
manifestOk: true,
|
|
incompatible: false,
|
|
failure: null,
|
|
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
|
|
stagedPending: null,
|
|
};
|
|
expect(evaluateReadiness(allOk).state).toBe("READY");
|
|
expect(
|
|
evaluateReadiness({
|
|
...allOk,
|
|
datasetPresent: false,
|
|
authenticity: "record-missing",
|
|
integrity: "files-missing",
|
|
schemaCompatible: false,
|
|
requiredSectionsOk: false,
|
|
requiredAssetsOk: false,
|
|
hadVerifiedDataset: false,
|
|
presentSections: [],
|
|
}).state,
|
|
).toBe("NOT_READY");
|
|
});
|
|
});
|