feat(foundry): add map zones v2 consumer policy
This commit is contained in:
parent
aa681ca069
commit
de060c105b
|
|
@ -57,6 +57,46 @@
|
|||
"staleAfterMs": null,
|
||||
"terminalStatuses": [],
|
||||
"removeMode": "canonical-tombstone-or-snapshot-rebase"
|
||||
},
|
||||
{
|
||||
"id": "map-zone-current-v2",
|
||||
"version": "2.0.0",
|
||||
"dataProductId": "map.zones.current.v2",
|
||||
"productVersion": "2.0.0",
|
||||
"freshness": "none",
|
||||
"staleAfterMs": null,
|
||||
"terminalStatuses": [],
|
||||
"consumerContract": {
|
||||
"ontologyRevision": "ontology.map.zone.v1",
|
||||
"deliveryMode": "snapshot+patch",
|
||||
"semanticTypes": [
|
||||
"map.zone"
|
||||
],
|
||||
"fieldProjection": [
|
||||
"display_name",
|
||||
"geometry_kind",
|
||||
"max_speed_kph",
|
||||
"schedule_timezone",
|
||||
"applies_to_couriers",
|
||||
"applies_to_kicksharing"
|
||||
],
|
||||
"fieldTypes": {
|
||||
"display_name": "string",
|
||||
"geometry_kind": "string",
|
||||
"max_speed_kph": "number",
|
||||
"schedule_timezone": "string",
|
||||
"applies_to_couriers": "boolean",
|
||||
"applies_to_kicksharing": "boolean"
|
||||
},
|
||||
"geometryTypes": [
|
||||
"Polygon",
|
||||
"MultiPolygon"
|
||||
],
|
||||
"subjectIdentity": "semantic-type+source-id",
|
||||
"snapshotMode": "atomic-replace",
|
||||
"patchMode": "atomic"
|
||||
},
|
||||
"removeMode": "canonical-tombstone-or-snapshot-rebase"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,23 @@ for (const policy of consumerPolicies) {
|
|||
requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`);
|
||||
requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`);
|
||||
requireValue(["none", "observed-at"].includes(freshness), `invalid freshness mode: ${policy.id ?? "unknown"}`);
|
||||
if (policy.consumerContract !== undefined) {
|
||||
const contract = policy.consumerContract;
|
||||
const keys = contract && typeof contract === "object" && !Array.isArray(contract) ? Object.keys(contract).sort() : [];
|
||||
requireValue(JSON.stringify(keys) === JSON.stringify(["deliveryMode", "fieldProjection", "fieldTypes", "geometryTypes", "ontologyRevision", "patchMode", "semanticTypes", "snapshotMode", "subjectIdentity"]), `invalid consumer contract keys: ${policy.id ?? "unknown"}`);
|
||||
requireValue(typeof contract?.ontologyRevision === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(contract.ontologyRevision), `invalid consumer ontology revision: ${policy.id ?? "unknown"}`);
|
||||
requireValue(contract?.deliveryMode === "snapshot+patch", `invalid consumer delivery mode: ${policy.id ?? "unknown"}`);
|
||||
requireValue(Array.isArray(contract?.semanticTypes) && contract.semanticTypes.length >= 1 && new Set(contract.semanticTypes).size === contract.semanticTypes.length, `invalid consumer semantic types: ${policy.id ?? "unknown"}`);
|
||||
requireValue(contract?.semanticTypes?.every((value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(value)), `invalid consumer semantic type: ${policy.id ?? "unknown"}`);
|
||||
requireValue(Array.isArray(contract?.fieldProjection) && contract.fieldProjection.length >= 1 && new Set(contract.fieldProjection).size === contract.fieldProjection.length, `invalid consumer field projection: ${policy.id ?? "unknown"}`);
|
||||
requireValue(contract?.fieldProjection?.every((value) => typeof value === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(value)), `invalid consumer projection field: ${policy.id ?? "unknown"}`);
|
||||
requireValue(contract?.fieldTypes && typeof contract.fieldTypes === "object" && !Array.isArray(contract.fieldTypes), `invalid consumer field types: ${policy.id ?? "unknown"}`);
|
||||
requireValue(JSON.stringify(Object.keys(contract?.fieldTypes ?? {})) === JSON.stringify(contract?.fieldProjection ?? []), `consumer field type order/scope mismatch: ${policy.id ?? "unknown"}`);
|
||||
requireValue(Object.values(contract?.fieldTypes ?? {}).every((value) => ["string", "number", "boolean"].includes(value)), `invalid consumer field type: ${policy.id ?? "unknown"}`);
|
||||
requireValue(JSON.stringify(contract?.geometryTypes) === JSON.stringify(["Polygon", "MultiPolygon"]), `invalid consumer geometry types: ${policy.id ?? "unknown"}`);
|
||||
requireValue(contract?.subjectIdentity === "semantic-type+source-id", `invalid consumer subject identity: ${policy.id ?? "unknown"}`);
|
||||
requireValue(contract?.snapshotMode === "atomic-replace" && contract?.patchMode === "atomic", `invalid consumer commit modes: ${policy.id ?? "unknown"}`);
|
||||
}
|
||||
if (statusContract === undefined) {
|
||||
requireValue(
|
||||
freshness === "none"
|
||||
|
|
@ -93,6 +110,27 @@ requireValue(movingObjectV4Policy?.statusContract?.attribute === "signal_state",
|
|||
requireValue(JSON.stringify(movingObjectV4Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v4 signal states must remain closed active/inactive");
|
||||
requireValue(movingObjectV4Policy?.statusContract?.freshness === "none" && movingObjectV4Policy?.staleAfterMs === null, "fleet.positions.current.v4 must not invent freshness states");
|
||||
|
||||
const zoneV1Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v1" && policy.productVersion === "1.0.0");
|
||||
requireValue(JSON.stringify(zoneV1Policy) === JSON.stringify({
|
||||
id: "map-zone-current-v1",
|
||||
version: "1.0.0",
|
||||
dataProductId: "map.zones.current.v1",
|
||||
productVersion: "1.0.0",
|
||||
freshness: "none",
|
||||
staleAfterMs: null,
|
||||
terminalStatuses: [],
|
||||
removeMode: "canonical-tombstone-or-snapshot-rebase",
|
||||
}), "historical map.zones.current.v1 consumer policy changed");
|
||||
|
||||
const zoneV2Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v2" && policy.productVersion === "2.0.0");
|
||||
const zoneV2Projection = ["display_name", "geometry_kind", "max_speed_kph", "schedule_timezone", "applies_to_couriers", "applies_to_kicksharing"];
|
||||
requireValue(zoneV2Policy?.id === "map-zone-current-v2" && zoneV2Policy?.version === "2.0.0", "map.zones.current.v2 consumer policy is missing");
|
||||
requireValue(zoneV2Policy?.freshness === "none" && zoneV2Policy?.staleAfterMs === null, "map.zones.current.v2 must not invent freshness states");
|
||||
requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.semanticTypes) === JSON.stringify(["map.zone"]), "map.zones.current.v2 semantic scope must be exact");
|
||||
requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.fieldProjection) === JSON.stringify(zoneV2Projection), "map.zones.current.v2 field projection must be exact");
|
||||
requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.geometryTypes) === JSON.stringify(["Polygon", "MultiPolygon"]), "map.zones.current.v2 geometry contract must be Polygon/MultiPolygon");
|
||||
requireValue(!/(provider|tenant|connection|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(zoneV2Policy)), "map.zones.current.v2 consumer policy crosses the transport/secret boundary");
|
||||
|
||||
const ids = components.components.map((component) => component.id);
|
||||
requireValue(new Set(ids).size === ids.length, "component ids must be unique");
|
||||
const candidateIds = candidates.candidates.map((component) => component.id);
|
||||
|
|
|
|||
|
|
@ -138,6 +138,94 @@ function activeReaderGrantGeneration(state) {
|
|||
return generation;
|
||||
}
|
||||
|
||||
function validateConsumerContract(value) {
|
||||
if (value === undefined) return null;
|
||||
const keys = value && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.keys(value).sort()
|
||||
: [];
|
||||
if (JSON.stringify(keys) !== JSON.stringify([
|
||||
"deliveryMode",
|
||||
"fieldProjection",
|
||||
"fieldTypes",
|
||||
"geometryTypes",
|
||||
"ontologyRevision",
|
||||
"patchMode",
|
||||
"semanticTypes",
|
||||
"snapshotMode",
|
||||
"subjectIdentity",
|
||||
])) throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : [];
|
||||
const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : [];
|
||||
const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : [];
|
||||
const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes)
|
||||
? value.fieldTypes
|
||||
: null;
|
||||
if (
|
||||
!identifier.test(String(value.ontologyRevision || ""))
|
||||
|| value.deliveryMode !== "snapshot+patch"
|
||||
|| semanticTypes.length === 0
|
||||
|| semanticTypes.length > 8
|
||||
|| new Set(semanticTypes).size !== semanticTypes.length
|
||||
|| semanticTypes.some((item) => !identifier.test(item))
|
||||
|| fieldProjection.length === 0
|
||||
|| fieldProjection.length > 32
|
||||
|| new Set(fieldProjection).size !== fieldProjection.length
|
||||
|| fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))
|
||||
|| !fieldTypes
|
||||
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|
||||
|| Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type))
|
||||
|| JSON.stringify(geometryTypes) !== JSON.stringify(["Polygon", "MultiPolygon"])
|
||||
|| value.subjectIdentity !== "semantic-type+source-id"
|
||||
|| value.snapshotMode !== "atomic-replace"
|
||||
|| value.patchMode !== "atomic"
|
||||
) throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
return {
|
||||
ontologyRevision: value.ontologyRevision,
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: [...semanticTypes],
|
||||
fieldProjection: [...fieldProjection],
|
||||
fieldTypes: { ...fieldTypes },
|
||||
geometryTypes: [...geometryTypes],
|
||||
subjectIdentity: "semantic-type+source-id",
|
||||
snapshotMode: "atomic-replace",
|
||||
patchMode: "atomic",
|
||||
};
|
||||
}
|
||||
|
||||
function assertConsumerContract(contract, product, binding) {
|
||||
if (!contract) return;
|
||||
const productFieldTypes = Object.fromEntries(contract.fieldProjection.map((field) => [
|
||||
field,
|
||||
product?.fieldContracts?.[field]?.type,
|
||||
]));
|
||||
if (
|
||||
product.ontologyRevision !== contract.ontologyRevision
|
||||
|| product.deliveryMode !== contract.deliveryMode
|
||||
|| JSON.stringify(product.semanticTypes) !== JSON.stringify(contract.semanticTypes)
|
||||
|| JSON.stringify(binding.semanticTypes) !== JSON.stringify(contract.semanticTypes)
|
||||
|| JSON.stringify(binding.fieldProjection) !== JSON.stringify(contract.fieldProjection)
|
||||
|| !Array.isArray(product.fields)
|
||||
|| contract.fieldProjection.some((field) => !product.fields.includes(field))
|
||||
|| JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes)
|
||||
) throw consumerError("data_product_consumer_contract_mismatch", 409);
|
||||
}
|
||||
|
||||
function assertEnvelopeProduct(envelope, product, errorCode) {
|
||||
if (envelope?.dataProduct?.id !== product.id || envelope?.dataProduct?.version !== product.version) {
|
||||
throw consumerError(errorCode, 502);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFactContract(fact, policy) {
|
||||
const contract = policy.consumerContract;
|
||||
if (!contract) return;
|
||||
if (
|
||||
!contract.semanticTypes.includes(fact.semanticType)
|
||||
|| !fact.geometry
|
||||
|| !contract.geometryTypes.includes(fact.geometry.type)
|
||||
) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
||||
}
|
||||
|
||||
function validatePolicy(policy, product) {
|
||||
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
|
||||
throw consumerError("data_product_consumer_policy_not_found", 409);
|
||||
|
|
@ -169,6 +257,7 @@ function validatePolicy(policy, product) {
|
|||
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
const consumerContract = validateConsumerContract(policy.consumerContract);
|
||||
return {
|
||||
id: String(policy.id || ""),
|
||||
version: String(policy.version || ""),
|
||||
|
|
@ -178,6 +267,7 @@ function validatePolicy(policy, product) {
|
|||
staleAfterMs: policy.staleAfterMs,
|
||||
terminalStatuses: [...terminalStatuses],
|
||||
...(statusContract ? { statusContract } : {}),
|
||||
...(consumerContract ? { consumerContract } : {}),
|
||||
removeMode: policy.removeMode,
|
||||
};
|
||||
}
|
||||
|
|
@ -347,6 +437,7 @@ export function createFoundryDataProductConsumerManager({
|
|||
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
|
||||
}
|
||||
const policy = validatePolicy(resolvePolicy(product), product);
|
||||
assertConsumerContract(policy.consumerContract, product, target.binding);
|
||||
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
|
||||
throw consumerError("data_product_consumer_status_field_not_projected", 409);
|
||||
}
|
||||
|
|
@ -473,9 +564,11 @@ export function createFoundryDataProductConsumerManager({
|
|||
await writeState(record);
|
||||
try {
|
||||
const snapshot = await fetchSnapshot(record);
|
||||
assertEnvelopeProduct(snapshot, record.state.product, "data_product_snapshot_product_mismatch");
|
||||
const previousKeys = new Set(Object.keys(record.state.subjects || {}));
|
||||
const subjects = {};
|
||||
for (const fact of snapshot.facts) {
|
||||
assertFactContract(fact, record.state.policy);
|
||||
const key = factKey(fact);
|
||||
subjects[key] = {
|
||||
fact,
|
||||
|
|
@ -645,6 +738,7 @@ export function createFoundryDataProductConsumerManager({
|
|||
const emittedOperations = [];
|
||||
for (const operation of patch.operations) {
|
||||
if (operation.op === "upsert") {
|
||||
assertFactContract(operation.fact, record.state.policy);
|
||||
const key = factKey(operation.fact);
|
||||
const subject = {
|
||||
fact: operation.fact,
|
||||
|
|
@ -735,6 +829,7 @@ export function createFoundryDataProductConsumerManager({
|
|||
try { payload = JSON.parse(event.data); } catch { payload = null; }
|
||||
const patch = sanitizePatch(payload, record.target.binding);
|
||||
if (!patch) throw consumerError("data_product_patch_contract_invalid", 502);
|
||||
assertEnvelopeProduct(patch, record.state.product, "data_product_patch_product_mismatch");
|
||||
await applyPatch(record, patch);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -614,3 +614,251 @@ test("v4 consumer rejects bindings that omit signal_state and snapshots outside
|
|||
await rm(invalidStateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const zoneV2Projection = [
|
||||
"display_name",
|
||||
"geometry_kind",
|
||||
"max_speed_kph",
|
||||
"schedule_timezone",
|
||||
"applies_to_couriers",
|
||||
"applies_to_kicksharing",
|
||||
];
|
||||
|
||||
const zoneV2Policy = {
|
||||
id: "map-zone-current-v2",
|
||||
version: "2.0.0",
|
||||
dataProductId: "map.zones.current.v2",
|
||||
productVersion: "2.0.0",
|
||||
freshness: "none",
|
||||
staleAfterMs: null,
|
||||
terminalStatuses: [],
|
||||
consumerContract: {
|
||||
ontologyRevision: "ontology.map.zone.v1",
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.zone"],
|
||||
fieldProjection: zoneV2Projection,
|
||||
fieldTypes: {
|
||||
display_name: "string",
|
||||
geometry_kind: "string",
|
||||
max_speed_kph: "number",
|
||||
schedule_timezone: "string",
|
||||
applies_to_couriers: "boolean",
|
||||
applies_to_kicksharing: "boolean",
|
||||
},
|
||||
geometryTypes: ["Polygon", "MultiPolygon"],
|
||||
subjectIdentity: "semantic-type+source-id",
|
||||
snapshotMode: "atomic-replace",
|
||||
patchMode: "atomic",
|
||||
},
|
||||
removeMode: "canonical-tombstone-or-snapshot-rebase",
|
||||
};
|
||||
|
||||
const zoneV2Product = {
|
||||
id: "map.zones.current.v2",
|
||||
version: "2.0.0",
|
||||
ontologyRevision: "ontology.map.zone.v1",
|
||||
deliveryMode: "snapshot+patch",
|
||||
semanticTypes: ["map.zone"],
|
||||
fields: [...zoneV2Projection, "geometry", "source_revision"],
|
||||
fieldContracts: {
|
||||
display_name: { type: "string", required: true },
|
||||
geometry_kind: { type: "string", required: true },
|
||||
max_speed_kph: { type: "number", required: false },
|
||||
schedule_timezone: { type: "string", required: false },
|
||||
applies_to_couriers: { type: "boolean", required: false },
|
||||
applies_to_kicksharing: { type: "boolean", required: false },
|
||||
},
|
||||
active: true,
|
||||
};
|
||||
|
||||
function zoneV2Target(fieldProjection = zoneV2Projection) {
|
||||
return {
|
||||
application: { id: "55555555-5555-4555-8555-555555555555" },
|
||||
page: { id: "map" },
|
||||
binding: {
|
||||
id: "depttrans-pmd-slow-zones",
|
||||
dataProductId: zoneV2Product.id,
|
||||
slotId: "points",
|
||||
delivery: "snapshot+patch",
|
||||
semanticTypes: ["map.zone"],
|
||||
fieldProjection: [...fieldProjection],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function zoneFact({ sourceId = "depttrans-zone-001", geometryType = "Polygon", maxSpeedKph = 20 } = {}) {
|
||||
const polygon = [[[37.60, 55.75], [37.61, 55.75], [37.61, 55.76], [37.60, 55.75]]];
|
||||
return {
|
||||
sourceId,
|
||||
semanticType: "map.zone",
|
||||
observedAt: "2026-07-21T10:00:00.000Z",
|
||||
receivedAt: "2026-07-21T10:00:01.000Z",
|
||||
attributes: {
|
||||
display_name: "Slow zone 001",
|
||||
geometry_kind: "polygon",
|
||||
max_speed_kph: maxSpeedKph,
|
||||
schedule_timezone: "Europe/Moscow",
|
||||
applies_to_couriers: true,
|
||||
applies_to_kicksharing: true,
|
||||
},
|
||||
geometry: geometryType === "MultiPolygon"
|
||||
? { type: "MultiPolygon", coordinates: [polygon] }
|
||||
: geometryType === "Polygon"
|
||||
? { type: "Polygon", coordinates: polygon }
|
||||
: { type: geometryType, coordinates: [37.61, 55.75] },
|
||||
};
|
||||
}
|
||||
|
||||
function zoneSnapshot({ cursor = "903", version = "2.0.0", facts = [zoneFact()] } = {}) {
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.snapshot/v1",
|
||||
dataProduct: { id: zoneV2Product.id, version },
|
||||
generatedAt: "2026-07-21T10:00:02.000Z",
|
||||
cursor,
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
function zonePatch(cursor, previousCursor, operations, version = "2.0.0") {
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.patch/v1",
|
||||
dataProduct: { id: zoneV2Product.id, version },
|
||||
cursor,
|
||||
previousCursor,
|
||||
emittedAt: "2026-07-21T10:00:03.000Z",
|
||||
operations,
|
||||
};
|
||||
}
|
||||
|
||||
test("zone v2 consumer pins provider-neutral contract and commits Polygon/MultiPolygon patches by stable subject identity", async () => {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-zone-v2-"));
|
||||
const currentTarget = zoneV2Target();
|
||||
const streamCounters = { open: 0, closed: 0 };
|
||||
const manager = createFoundryDataProductConsumerManager({
|
||||
stateDir,
|
||||
dataPlaneUrl: "http://edp.test",
|
||||
resolveTarget: async () => structuredClone(currentTarget),
|
||||
readReaderToken: async () => "ndc_edprb_zone-reader-capability",
|
||||
inspectReaderGrant: async () => ({ product: zoneV2Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
|
||||
resolvePolicy: () => zoneV2Policy,
|
||||
sanitizeSnapshot: (value) => value,
|
||||
sanitizePatch: (value) => value,
|
||||
fetchImpl: async () => Response.json(zoneSnapshot()),
|
||||
openStream: async ({ signal }) => sseResponse(
|
||||
zonePatch("904", "903", [{ op: "upsert", fact: zoneFact({ geometryType: "MultiPolygon", maxSpeedKph: 15 }) }]),
|
||||
signal,
|
||||
streamCounters,
|
||||
),
|
||||
now: () => Date.parse("2026-07-21T10:00:10.000Z"),
|
||||
idleStopMs: 10,
|
||||
reconnectMinMs: 10,
|
||||
reconnectMaxMs: 20,
|
||||
});
|
||||
try {
|
||||
const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id };
|
||||
const plan = await manager.plan(input);
|
||||
assert.equal(plan.configuration.product.version, "2.0.0");
|
||||
assert.deepEqual(plan.configuration.policy.consumerContract.semanticTypes, ["map.zone"]);
|
||||
assert.deepEqual(plan.configuration.policy.consumerContract.fieldProjection, zoneV2Projection);
|
||||
assert.deepEqual(plan.configuration.policy.consumerContract.geometryTypes, ["Polygon", "MultiPolygon"]);
|
||||
assert.equal(JSON.stringify(plan).includes("http://edp.test"), false);
|
||||
assert.equal(JSON.stringify(plan).includes("ndc_edprb_"), false);
|
||||
|
||||
const applied = await manager.apply({ ...input, planId: plan.planId });
|
||||
assert.equal(applied.consumer.cursor, "903");
|
||||
assert.equal(applied.consumer.subjectCount, 1);
|
||||
assert.equal(applied.consumer.subjects[0].subjectId, "depttrans-zone-001");
|
||||
assert.equal(applied.consumer.subjects[0].status, "active");
|
||||
assert.equal(applied.consumer.metrics.staleTransitions, 0);
|
||||
|
||||
const lease = await manager.subscribe({ ...currentTarget, after: "903" }, () => undefined);
|
||||
await waitUntil(async () => (await manager.status(input)).consumer.cursor === "904", "zone_patch_cursor_904");
|
||||
const patched = await manager.snapshot(currentTarget);
|
||||
assert.equal(patched.facts.length, 1);
|
||||
assert.equal(patched.facts[0].sourceId, "depttrans-zone-001");
|
||||
assert.equal(patched.facts[0].geometry.type, "MultiPolygon");
|
||||
assert.equal(patched.facts[0].attributes.max_speed_kph, 15);
|
||||
assert.equal((await manager.status(input)).consumer.upstreamStreamCount, 1);
|
||||
lease.release();
|
||||
await waitUntil(() => streamCounters.closed === 1, "zone_stream_closed");
|
||||
await waitUntil(async () => (await manager.status(input)).consumer.runtimeState === "idle", "zone_stream_idle");
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
await rm(stateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
||||
}
|
||||
});
|
||||
|
||||
test("zone v2 consumer fails closed on projection, product, envelope and geometry contract drift", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "projection",
|
||||
target: zoneV2Target(zoneV2Projection.slice(0, -1)),
|
||||
product: zoneV2Product,
|
||||
policy: zoneV2Policy,
|
||||
snapshot: zoneSnapshot(),
|
||||
planError: /data_product_consumer_contract_mismatch/,
|
||||
},
|
||||
{
|
||||
name: "field-type",
|
||||
target: zoneV2Target(),
|
||||
product: { ...zoneV2Product, fieldContracts: { ...zoneV2Product.fieldContracts, max_speed_kph: { type: "string" } } },
|
||||
policy: zoneV2Policy,
|
||||
snapshot: zoneSnapshot(),
|
||||
planError: /data_product_consumer_contract_mismatch/,
|
||||
},
|
||||
{
|
||||
name: "product-version",
|
||||
target: zoneV2Target(),
|
||||
product: { ...zoneV2Product, version: "2.0.1" },
|
||||
policy: zoneV2Policy,
|
||||
snapshot: zoneSnapshot({ version: "2.0.1" }),
|
||||
planError: /data_product_consumer_policy_not_found/,
|
||||
},
|
||||
{
|
||||
name: "envelope-version",
|
||||
target: zoneV2Target(),
|
||||
product: zoneV2Product,
|
||||
policy: zoneV2Policy,
|
||||
snapshot: zoneSnapshot({ version: "2.0.1" }),
|
||||
applyError: /data_product_snapshot_product_mismatch/,
|
||||
},
|
||||
{
|
||||
name: "point-geometry",
|
||||
target: zoneV2Target(),
|
||||
product: zoneV2Product,
|
||||
policy: zoneV2Policy,
|
||||
snapshot: zoneSnapshot({ facts: [zoneFact({ geometryType: "Point" })] }),
|
||||
applyError: /data_product_consumer_fact_contract_invalid/,
|
||||
},
|
||||
];
|
||||
|
||||
for (const variant of cases) {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-zone-v2-${variant.name}-`));
|
||||
const manager = createFoundryDataProductConsumerManager({
|
||||
stateDir,
|
||||
dataPlaneUrl: "http://edp.test",
|
||||
resolveTarget: async () => structuredClone(variant.target),
|
||||
readReaderToken: async () => "ndc_edprb_zone-reader-capability",
|
||||
inspectReaderGrant: async () => ({ product: variant.product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
|
||||
resolvePolicy: () => variant.policy,
|
||||
sanitizeSnapshot: (value) => value,
|
||||
sanitizePatch: (value) => value,
|
||||
fetchImpl: async () => Response.json(variant.snapshot),
|
||||
});
|
||||
const input = { applicationId: variant.target.application.id, pageId: variant.target.page.id, bindingId: variant.target.binding.id };
|
||||
try {
|
||||
if (variant.planError) {
|
||||
await assert.rejects(manager.plan(input), variant.planError);
|
||||
continue;
|
||||
}
|
||||
const plan = await manager.plan(input);
|
||||
await assert.rejects(manager.apply({ ...input, planId: plan.planId }), variant.applyError);
|
||||
const failed = await manager.status(input);
|
||||
assert.equal(failed.consumer.subjectCount, 0);
|
||||
assert.equal(failed.consumer.runtimeState, "error");
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue