feat(foundry): support typed non-geometric subject aspects
This commit is contained in:
parent
dc82ae4c07
commit
03aa9e3e7e
|
|
@ -68,6 +68,87 @@
|
||||||
},
|
},
|
||||||
"removeMode": "canonical-tombstone-or-snapshot-rebase"
|
"removeMode": "canonical-tombstone-or-snapshot-rebase"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "map-moving-object-unit-profile-current-v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dataProductId": "fleet.units.profile.current.v1",
|
||||||
|
"productVersion": "1.0.0",
|
||||||
|
"freshness": "none",
|
||||||
|
"staleAfterMs": null,
|
||||||
|
"terminalStatuses": [],
|
||||||
|
"consumerContract": {
|
||||||
|
"ontologyRevision": "ontology.map.moving_object.v3",
|
||||||
|
"deliveryMode": "snapshot+patch",
|
||||||
|
"semanticTypes": [
|
||||||
|
"map.moving_object"
|
||||||
|
],
|
||||||
|
"fieldProjection": [
|
||||||
|
"corrected_engine_hours_factor",
|
||||||
|
"corrected_mileage_factor",
|
||||||
|
"display_name",
|
||||||
|
"filter_by_satellite_count_enabled",
|
||||||
|
"filter_by_satellite_count_value",
|
||||||
|
"filter_emissions",
|
||||||
|
"hardware_manufacturer_name",
|
||||||
|
"hardware_port",
|
||||||
|
"hardware_type_class",
|
||||||
|
"hardware_type_name",
|
||||||
|
"limit_acceleration",
|
||||||
|
"lost_connection_enabled",
|
||||||
|
"lost_connection_time_value",
|
||||||
|
"maximum_permissible_speed",
|
||||||
|
"maximum_valid_height",
|
||||||
|
"maximum_valid_speed",
|
||||||
|
"mileage_by_ignition",
|
||||||
|
"minimum_movement_speed",
|
||||||
|
"minimum_movement_time",
|
||||||
|
"minimum_parking_time",
|
||||||
|
"minimum_stop_time",
|
||||||
|
"minimum_trip_distance",
|
||||||
|
"minimum_valid_height",
|
||||||
|
"speed_parameter",
|
||||||
|
"trip_detection_type",
|
||||||
|
"unit_type_class",
|
||||||
|
"unit_type_name",
|
||||||
|
"use_odometer"
|
||||||
|
],
|
||||||
|
"fieldTypes": {
|
||||||
|
"corrected_engine_hours_factor": "number",
|
||||||
|
"corrected_mileage_factor": "number",
|
||||||
|
"display_name": "string",
|
||||||
|
"filter_by_satellite_count_enabled": "boolean",
|
||||||
|
"filter_by_satellite_count_value": "number",
|
||||||
|
"filter_emissions": "number",
|
||||||
|
"hardware_manufacturer_name": "string",
|
||||||
|
"hardware_port": "number",
|
||||||
|
"hardware_type_class": "string",
|
||||||
|
"hardware_type_name": "string",
|
||||||
|
"limit_acceleration": "number",
|
||||||
|
"lost_connection_enabled": "boolean",
|
||||||
|
"lost_connection_time_value": "number",
|
||||||
|
"maximum_permissible_speed": "number",
|
||||||
|
"maximum_valid_height": "number",
|
||||||
|
"maximum_valid_speed": "number",
|
||||||
|
"mileage_by_ignition": "boolean",
|
||||||
|
"minimum_movement_speed": "number",
|
||||||
|
"minimum_movement_time": "number",
|
||||||
|
"minimum_parking_time": "number",
|
||||||
|
"minimum_stop_time": "number",
|
||||||
|
"minimum_trip_distance": "number",
|
||||||
|
"minimum_valid_height": "number",
|
||||||
|
"speed_parameter": "string",
|
||||||
|
"trip_detection_type": "string",
|
||||||
|
"unit_type_class": "string",
|
||||||
|
"unit_type_name": "string",
|
||||||
|
"use_odometer": "boolean"
|
||||||
|
},
|
||||||
|
"geometryTypes": [],
|
||||||
|
"subjectIdentity": "semantic-type+source-id",
|
||||||
|
"snapshotMode": "atomic-replace",
|
||||||
|
"patchMode": "atomic"
|
||||||
|
},
|
||||||
|
"removeMode": "canonical-tombstone-or-snapshot-rebase"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "map-zone-current-v1",
|
"id": "map-zone-current-v1",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,19 @@ requireValue(new Set(consumerPolicyProducts).size === consumerPolicyProducts.len
|
||||||
for (const policy of consumerPolicies) {
|
for (const policy of consumerPolicies) {
|
||||||
const statusContract = policy.statusContract;
|
const statusContract = policy.statusContract;
|
||||||
const freshness = policy.freshness ?? statusContract?.freshness ?? "observed-at";
|
const freshness = policy.freshness ?? statusContract?.freshness ?? "observed-at";
|
||||||
|
const allowedPolicyKeys = new Set([
|
||||||
|
"id",
|
||||||
|
"version",
|
||||||
|
"dataProductId",
|
||||||
|
"productVersion",
|
||||||
|
"freshness",
|
||||||
|
"staleAfterMs",
|
||||||
|
"terminalStatuses",
|
||||||
|
"statusContract",
|
||||||
|
"consumerContract",
|
||||||
|
"removeMode",
|
||||||
|
]);
|
||||||
|
requireValue(Object.keys(policy).every((key) => allowedPolicyKeys.has(key)), `unsupported data product consumer policy key: ${policy.id ?? "unknown"}`);
|
||||||
requireValue(typeof policy.id === "string" && /^[a-z0-9._-]{1,160}$/.test(policy.id), `invalid data product consumer policy id: ${policy.id ?? "unknown"}`);
|
requireValue(typeof policy.id === "string" && /^[a-z0-9._-]{1,160}$/.test(policy.id), `invalid data product consumer policy id: ${policy.id ?? "unknown"}`);
|
||||||
requireValue(typeof policy.version === "string" && /^\d+\.\d+\.\d+$/.test(policy.version), `invalid data product consumer policy version: ${policy.id ?? "unknown"}`);
|
requireValue(typeof policy.version === "string" && /^\d+\.\d+\.\d+$/.test(policy.version), `invalid data product consumer policy version: ${policy.id ?? "unknown"}`);
|
||||||
requireValue(typeof policy.dataProductId === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(policy.dataProductId), `invalid data product consumer policy product: ${policy.id ?? "unknown"}`);
|
requireValue(typeof policy.dataProductId === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(policy.dataProductId), `invalid data product consumer policy product: ${policy.id ?? "unknown"}`);
|
||||||
|
|
@ -90,7 +103,14 @@ for (const policy of consumerPolicies) {
|
||||||
requireValue(contract?.fieldTypes && typeof contract.fieldTypes === "object" && !Array.isArray(contract.fieldTypes), `invalid consumer field types: ${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(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(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"}`);
|
const supportedGeometryTypes = ["Point", "LineString", "Polygon", "MultiPolygon"];
|
||||||
|
requireValue(
|
||||||
|
Array.isArray(contract?.geometryTypes)
|
||||||
|
&& contract.geometryTypes.length <= supportedGeometryTypes.length
|
||||||
|
&& new Set(contract.geometryTypes).size === contract.geometryTypes.length
|
||||||
|
&& contract.geometryTypes.every((value) => supportedGeometryTypes.includes(value)),
|
||||||
|
`invalid consumer geometry types: ${policy.id ?? "unknown"}`,
|
||||||
|
);
|
||||||
requireValue(contract?.subjectIdentity === "semantic-type+source-id", `invalid consumer subject identity: ${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"}`);
|
requireValue(contract?.snapshotMode === "atomic-replace" && contract?.patchMode === "atomic", `invalid consumer commit modes: ${policy.id ?? "unknown"}`);
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +167,44 @@ requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.fieldProjection) ===
|
||||||
requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.geometryTypes) === JSON.stringify(["Polygon", "MultiPolygon"]), "map.zones.current.v2 geometry contract must be Polygon/MultiPolygon");
|
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");
|
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 unitProfileV1Policy = consumerPolicies.find((policy) => policy.dataProductId === "fleet.units.profile.current.v1" && policy.productVersion === "1.0.0");
|
||||||
|
const unitProfileV1Projection = [
|
||||||
|
"corrected_engine_hours_factor",
|
||||||
|
"corrected_mileage_factor",
|
||||||
|
"display_name",
|
||||||
|
"filter_by_satellite_count_enabled",
|
||||||
|
"filter_by_satellite_count_value",
|
||||||
|
"filter_emissions",
|
||||||
|
"hardware_manufacturer_name",
|
||||||
|
"hardware_port",
|
||||||
|
"hardware_type_class",
|
||||||
|
"hardware_type_name",
|
||||||
|
"limit_acceleration",
|
||||||
|
"lost_connection_enabled",
|
||||||
|
"lost_connection_time_value",
|
||||||
|
"maximum_permissible_speed",
|
||||||
|
"maximum_valid_height",
|
||||||
|
"maximum_valid_speed",
|
||||||
|
"mileage_by_ignition",
|
||||||
|
"minimum_movement_speed",
|
||||||
|
"minimum_movement_time",
|
||||||
|
"minimum_parking_time",
|
||||||
|
"minimum_stop_time",
|
||||||
|
"minimum_trip_distance",
|
||||||
|
"minimum_valid_height",
|
||||||
|
"speed_parameter",
|
||||||
|
"trip_detection_type",
|
||||||
|
"unit_type_class",
|
||||||
|
"unit_type_name",
|
||||||
|
"use_odometer",
|
||||||
|
];
|
||||||
|
requireValue(unitProfileV1Policy?.id === "map-moving-object-unit-profile-current-v1" && unitProfileV1Policy?.version === "1.0.0", "fleet.units.profile.current.v1 consumer policy is missing");
|
||||||
|
requireValue(unitProfileV1Policy?.freshness === "none" && unitProfileV1Policy?.staleAfterMs === null, "fleet.units.profile.current.v1 must not invent freshness states");
|
||||||
|
requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.semanticTypes) === JSON.stringify(["map.moving_object"]), "fleet.units.profile.current.v1 semantic scope must be exact");
|
||||||
|
requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.fieldProjection) === JSON.stringify(unitProfileV1Projection), "fleet.units.profile.current.v1 field projection must be exact");
|
||||||
|
requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.geometryTypes) === JSON.stringify([]), "fleet.units.profile.current.v1 must remain a non-geometric aspect");
|
||||||
|
requireValue(!/(provider|tenant|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(unitProfileV1Policy)), "fleet.units.profile.current.v1 consumer policy crosses the transport/secret boundary");
|
||||||
|
|
||||||
const ids = components.components.map((component) => component.id);
|
const ids = components.components.map((component) => component.id);
|
||||||
requireValue(new Set(ids).size === ids.length, "component ids must be unique");
|
requireValue(new Set(ids).size === ids.length, "component ids must be unique");
|
||||||
const candidateIds = candidates.candidates.map((component) => component.id);
|
const candidateIds = candidates.candidates.map((component) => component.id);
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ function validateConsumerContract(value) {
|
||||||
const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : [];
|
const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : [];
|
||||||
const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : [];
|
const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : [];
|
||||||
const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : [];
|
const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : [];
|
||||||
|
const supportedGeometryTypes = new Set(["Point", "LineString", "Polygon", "MultiPolygon"]);
|
||||||
const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes)
|
const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes)
|
||||||
? value.fieldTypes
|
? value.fieldTypes
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -174,7 +175,9 @@ function validateConsumerContract(value) {
|
||||||
|| !fieldTypes
|
|| !fieldTypes
|
||||||
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|
||||||
|| Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type))
|
|| Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type))
|
||||||
|| JSON.stringify(geometryTypes) !== JSON.stringify(["Polygon", "MultiPolygon"])
|
|| geometryTypes.length > supportedGeometryTypes.size
|
||||||
|
|| new Set(geometryTypes).size !== geometryTypes.length
|
||||||
|
|| geometryTypes.some((type) => !supportedGeometryTypes.has(type))
|
||||||
|| value.subjectIdentity !== "semantic-type+source-id"
|
|| value.subjectIdentity !== "semantic-type+source-id"
|
||||||
|| value.snapshotMode !== "atomic-replace"
|
|| value.snapshotMode !== "atomic-replace"
|
||||||
|| value.patchMode !== "atomic"
|
|| value.patchMode !== "atomic"
|
||||||
|
|
@ -198,6 +201,8 @@ function assertConsumerContract(contract, product, binding) {
|
||||||
field,
|
field,
|
||||||
product?.fieldContracts?.[field]?.type,
|
product?.fieldContracts?.[field]?.type,
|
||||||
]));
|
]));
|
||||||
|
const productHasGeometry = Array.isArray(product.fields) && product.fields.includes("geometry");
|
||||||
|
const contractHasGeometry = contract.geometryTypes.length > 0;
|
||||||
if (
|
if (
|
||||||
product.ontologyRevision !== contract.ontologyRevision
|
product.ontologyRevision !== contract.ontologyRevision
|
||||||
|| product.deliveryMode !== contract.deliveryMode
|
|| product.deliveryMode !== contract.deliveryMode
|
||||||
|
|
@ -207,6 +212,8 @@ function assertConsumerContract(contract, product, binding) {
|
||||||
|| !Array.isArray(product.fields)
|
|| !Array.isArray(product.fields)
|
||||||
|| contract.fieldProjection.some((field) => !product.fields.includes(field))
|
|| contract.fieldProjection.some((field) => !product.fields.includes(field))
|
||||||
|| JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes)
|
|| JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes)
|
||||||
|
|| productHasGeometry !== contractHasGeometry
|
||||||
|
|| (contractHasGeometry && product?.fieldContracts?.geometry?.type !== "geometry")
|
||||||
) throw consumerError("data_product_consumer_contract_mismatch", 409);
|
) throw consumerError("data_product_consumer_contract_mismatch", 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,11 +226,17 @@ function assertEnvelopeProduct(envelope, product, errorCode) {
|
||||||
function assertFactContract(fact, policy) {
|
function assertFactContract(fact, policy) {
|
||||||
const contract = policy.consumerContract;
|
const contract = policy.consumerContract;
|
||||||
if (!contract) return;
|
if (!contract) return;
|
||||||
if (
|
if (!contract.semanticTypes.includes(fact.semanticType)) {
|
||||||
!contract.semanticTypes.includes(fact.semanticType)
|
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
||||||
|| !fact.geometry
|
}
|
||||||
|| !contract.geometryTypes.includes(fact.geometry.type)
|
const hasGeometry = fact.geometry !== undefined && fact.geometry !== null;
|
||||||
) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
if (contract.geometryTypes.length === 0) {
|
||||||
|
if (hasGeometry) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasGeometry || !contract.geometryTypes.includes(fact.geometry.type)) {
|
||||||
|
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePolicy(policy, product) {
|
function validatePolicy(policy, product) {
|
||||||
|
|
|
||||||
|
|
@ -667,6 +667,7 @@ const zoneV2Product = {
|
||||||
schedule_timezone: { type: "string", required: false },
|
schedule_timezone: { type: "string", required: false },
|
||||||
applies_to_couriers: { type: "boolean", required: false },
|
applies_to_couriers: { type: "boolean", required: false },
|
||||||
applies_to_kicksharing: { type: "boolean", required: false },
|
applies_to_kicksharing: { type: "boolean", required: false },
|
||||||
|
geometry: { type: "geometry", required: true },
|
||||||
},
|
},
|
||||||
active: true,
|
active: true,
|
||||||
};
|
};
|
||||||
|
|
@ -862,3 +863,219 @@ test("zone v2 consumer fails closed on projection, product, envelope and geometr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unitProfileV1Projection = [
|
||||||
|
"corrected_engine_hours_factor",
|
||||||
|
"corrected_mileage_factor",
|
||||||
|
"display_name",
|
||||||
|
"filter_by_satellite_count_enabled",
|
||||||
|
"filter_by_satellite_count_value",
|
||||||
|
"filter_emissions",
|
||||||
|
"hardware_manufacturer_name",
|
||||||
|
"hardware_port",
|
||||||
|
"hardware_type_class",
|
||||||
|
"hardware_type_name",
|
||||||
|
"limit_acceleration",
|
||||||
|
"lost_connection_enabled",
|
||||||
|
"lost_connection_time_value",
|
||||||
|
"maximum_permissible_speed",
|
||||||
|
"maximum_valid_height",
|
||||||
|
"maximum_valid_speed",
|
||||||
|
"mileage_by_ignition",
|
||||||
|
"minimum_movement_speed",
|
||||||
|
"minimum_movement_time",
|
||||||
|
"minimum_parking_time",
|
||||||
|
"minimum_stop_time",
|
||||||
|
"minimum_trip_distance",
|
||||||
|
"minimum_valid_height",
|
||||||
|
"speed_parameter",
|
||||||
|
"trip_detection_type",
|
||||||
|
"unit_type_class",
|
||||||
|
"unit_type_name",
|
||||||
|
"use_odometer",
|
||||||
|
];
|
||||||
|
|
||||||
|
const unitProfileV1FieldTypes = {
|
||||||
|
corrected_engine_hours_factor: "number",
|
||||||
|
corrected_mileage_factor: "number",
|
||||||
|
display_name: "string",
|
||||||
|
filter_by_satellite_count_enabled: "boolean",
|
||||||
|
filter_by_satellite_count_value: "number",
|
||||||
|
filter_emissions: "number",
|
||||||
|
hardware_manufacturer_name: "string",
|
||||||
|
hardware_port: "number",
|
||||||
|
hardware_type_class: "string",
|
||||||
|
hardware_type_name: "string",
|
||||||
|
limit_acceleration: "number",
|
||||||
|
lost_connection_enabled: "boolean",
|
||||||
|
lost_connection_time_value: "number",
|
||||||
|
maximum_permissible_speed: "number",
|
||||||
|
maximum_valid_height: "number",
|
||||||
|
maximum_valid_speed: "number",
|
||||||
|
mileage_by_ignition: "boolean",
|
||||||
|
minimum_movement_speed: "number",
|
||||||
|
minimum_movement_time: "number",
|
||||||
|
minimum_parking_time: "number",
|
||||||
|
minimum_stop_time: "number",
|
||||||
|
minimum_trip_distance: "number",
|
||||||
|
minimum_valid_height: "number",
|
||||||
|
speed_parameter: "string",
|
||||||
|
trip_detection_type: "string",
|
||||||
|
unit_type_class: "string",
|
||||||
|
unit_type_name: "string",
|
||||||
|
use_odometer: "boolean",
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitProfileV1Policy = {
|
||||||
|
id: "map-moving-object-unit-profile-current-v1",
|
||||||
|
version: "1.0.0",
|
||||||
|
dataProductId: "fleet.units.profile.current.v1",
|
||||||
|
productVersion: "1.0.0",
|
||||||
|
freshness: "none",
|
||||||
|
staleAfterMs: null,
|
||||||
|
terminalStatuses: [],
|
||||||
|
consumerContract: {
|
||||||
|
ontologyRevision: "ontology.map.moving_object.v3",
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fieldProjection: unitProfileV1Projection,
|
||||||
|
fieldTypes: unitProfileV1FieldTypes,
|
||||||
|
geometryTypes: [],
|
||||||
|
subjectIdentity: "semantic-type+source-id",
|
||||||
|
snapshotMode: "atomic-replace",
|
||||||
|
patchMode: "atomic",
|
||||||
|
},
|
||||||
|
removeMode: "canonical-tombstone-or-snapshot-rebase",
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitProfileV1Product = {
|
||||||
|
id: "fleet.units.profile.current.v1",
|
||||||
|
version: "1.0.0",
|
||||||
|
ontologyRevision: "ontology.map.moving_object.v3",
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fields: [...unitProfileV1Projection],
|
||||||
|
fieldContracts: Object.fromEntries(unitProfileV1Projection.map((field) => [
|
||||||
|
field,
|
||||||
|
{ type: unitProfileV1FieldTypes[field], required: field === "display_name" },
|
||||||
|
])),
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function unitProfileV1Target(fieldProjection = unitProfileV1Projection) {
|
||||||
|
return {
|
||||||
|
application: { id: "66666666-6666-4666-8666-666666666666" },
|
||||||
|
page: { id: "map" },
|
||||||
|
binding: {
|
||||||
|
id: "trike-unit-profile",
|
||||||
|
dataProductId: unitProfileV1Product.id,
|
||||||
|
slotId: "points",
|
||||||
|
delivery: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fieldProjection: [...fieldProjection],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitProfileV1Fact({ geometry } = {}) {
|
||||||
|
return {
|
||||||
|
sourceId: "gelios-unit-501",
|
||||||
|
semanticType: "map.moving_object",
|
||||||
|
observedAt: "2026-07-23T06:28:52.000Z",
|
||||||
|
receivedAt: "2026-07-23T06:28:53.000Z",
|
||||||
|
attributes: {
|
||||||
|
display_name: "501 B2",
|
||||||
|
hardware_manufacturer_name: "Gelios",
|
||||||
|
hardware_type_name: "Trike tracker",
|
||||||
|
maximum_permissible_speed: 25,
|
||||||
|
minimum_movement_speed: 3,
|
||||||
|
use_odometer: true,
|
||||||
|
},
|
||||||
|
...(geometry === undefined ? {} : { geometry }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitProfileV1Snapshot(facts = [unitProfileV1Fact()]) {
|
||||||
|
return {
|
||||||
|
schemaVersion: "nodedc.data-product.snapshot/v1",
|
||||||
|
dataProduct: { id: unitProfileV1Product.id, version: unitProfileV1Product.version },
|
||||||
|
generatedAt: "2026-07-23T06:28:54.000Z",
|
||||||
|
cursor: "107",
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("non-geometric unit profile aspect is strictly typed and joins by stable subject identity", async () => {
|
||||||
|
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-unit-profile-v1-"));
|
||||||
|
const currentTarget = unitProfileV1Target();
|
||||||
|
const manager = createFoundryDataProductConsumerManager({
|
||||||
|
stateDir,
|
||||||
|
dataPlaneUrl: "http://edp.test",
|
||||||
|
resolveTarget: async () => structuredClone(currentTarget),
|
||||||
|
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
|
||||||
|
inspectReaderGrant: async () => ({ product: unitProfileV1Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
|
||||||
|
resolvePolicy: () => unitProfileV1Policy,
|
||||||
|
sanitizeSnapshot: (value) => value,
|
||||||
|
sanitizePatch: (value) => value,
|
||||||
|
fetchImpl: async () => Response.json(unitProfileV1Snapshot()),
|
||||||
|
});
|
||||||
|
const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id };
|
||||||
|
try {
|
||||||
|
const plan = await manager.plan(input);
|
||||||
|
assert.deepEqual(plan.configuration.policy.consumerContract.geometryTypes, []);
|
||||||
|
assert.deepEqual(plan.configuration.policy.consumerContract.fieldProjection, unitProfileV1Projection);
|
||||||
|
const applied = await manager.apply({ ...input, planId: plan.planId });
|
||||||
|
assert.equal(applied.consumer.subjectCount, 1);
|
||||||
|
assert.equal(applied.consumer.subjects[0].subjectId, "gelios-unit-501");
|
||||||
|
assert.equal(applied.consumer.subjects[0].status, "active");
|
||||||
|
const persisted = await manager.snapshot(currentTarget);
|
||||||
|
assert.equal(persisted.facts[0].geometry, undefined);
|
||||||
|
assert.equal(persisted.facts[0].attributes.hardware_type_name, "Trike tracker");
|
||||||
|
} finally {
|
||||||
|
await manager.shutdown();
|
||||||
|
await rm(stateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-geometric unit profile aspect fails closed on projection drift and injected geometry", async () => {
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
name: "projection",
|
||||||
|
target: unitProfileV1Target(unitProfileV1Projection.slice(1)),
|
||||||
|
snapshot: unitProfileV1Snapshot(),
|
||||||
|
planError: /data_product_consumer_contract_mismatch/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "geometry",
|
||||||
|
target: unitProfileV1Target(),
|
||||||
|
snapshot: unitProfileV1Snapshot([unitProfileV1Fact({ geometry: { type: "Point", coordinates: [37.61, 55.75] } })]),
|
||||||
|
applyError: /data_product_consumer_fact_contract_invalid/,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const variant of variants) {
|
||||||
|
const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-unit-profile-v1-${variant.name}-`));
|
||||||
|
const manager = createFoundryDataProductConsumerManager({
|
||||||
|
stateDir,
|
||||||
|
dataPlaneUrl: "http://edp.test",
|
||||||
|
resolveTarget: async () => structuredClone(variant.target),
|
||||||
|
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
|
||||||
|
inspectReaderGrant: async () => ({ product: unitProfileV1Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
|
||||||
|
resolvePolicy: () => unitProfileV1Policy,
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
const plan = await manager.plan(input);
|
||||||
|
await assert.rejects(manager.apply({ ...input, planId: plan.planId }), variant.applyError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await manager.shutdown();
|
||||||
|
await rm(stateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue