feat: add pinned inventory location enrichment
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
CAPABILITY_DEFINITIONS,
|
||||
CAPABILITY_REQUIREMENTS,
|
||||
ESI_ASSETS_SCOPE,
|
||||
ESI_STRUCTURES_SCOPE,
|
||||
ESI_WALLET_SCOPE,
|
||||
gateCapabilityWithAuthorization,
|
||||
validateCapabilityScopes,
|
||||
@@ -129,6 +130,9 @@ describe('T02 typed contract fixtures', () => {
|
||||
expect(CAPABILITY_REQUIREMENTS.wallet_balance).toEqual(['wallet_balance']);
|
||||
expect(CAPABILITY_DEFINITIONS.wallet_balance.requiredScopes).toEqual([ESI_WALLET_SCOPE]);
|
||||
expect(CAPABILITY_DEFINITIONS.inventory_query.requiredScopes).toEqual([ESI_ASSETS_SCOPE]);
|
||||
expect(CAPABILITY_DEFINITIONS.inventory_locations.requiredScopes).toEqual([ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE]);
|
||||
expect(validateCapabilityScopes('inventory_locations', [ESI_ASSETS_SCOPE]).missingScopes).toEqual([ESI_STRUCTURES_SCOPE]);
|
||||
expect(validateCapabilityScopes('inventory_locations', [ESI_ASSETS_SCOPE, ESI_STRUCTURES_SCOPE]).valid).toBe(true);
|
||||
expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE]).valid).toBe(true);
|
||||
expect(validateCapabilityScopes('wallet_balance', [ESI_WALLET_SCOPE, ESI_WALLET_SCOPE]).duplicateScopes).toEqual([ESI_WALLET_SCOPE]);
|
||||
expect(validateCapabilityScopes('wallet_balance', []).missingScopes).toEqual([ESI_WALLET_SCOPE]);
|
||||
|
||||
+18
-4
@@ -16,6 +16,7 @@ import {
|
||||
ESI_ASSET_NAME_MAX_RESPONSE_BYTES,
|
||||
ESI_ASSETS_SCOPE,
|
||||
ESI_COMPATIBILITY_DATE,
|
||||
ESI_STRUCTURES_SCOPE,
|
||||
ESI_READ_OPERATIONS,
|
||||
ESI_WALLET_MAX_RESPONSE_BYTES,
|
||||
ESI_WALLET_SCOPE,
|
||||
@@ -97,6 +98,18 @@ describe("central ESI client", () => {
|
||||
expect(JSON.stringify(queued.calls)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("reads private structures through the fixed lossless endpoint and isolates its scope", async () => {
|
||||
const queued = queueFetch([response('{"structure_id":9007199254740993,"name":"Private Keep","solar_system_id":30000142,"owner_id":99000001,"type_id":35832,"position":{"x":1,"y":2,"z":3}}', { "Cache-Control": "max-age=60" })]);
|
||||
const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 1_000 });
|
||||
const result = await client.getStructure(42, "9007199254740993", "secret-token", authority("u", 42, [ESI_STRUCTURES_SCOPE]));
|
||||
expect(result.structureId).toBe("9007199254740993");
|
||||
expect(result.solarSystemId).toBe("30000142");
|
||||
expect(queued.calls[0]?.url).toBe("https://esi.evetech.net/universe/structures/9007199254740993");
|
||||
expect(new Headers(queued.calls[0]?.init.headers).get("authorization")).toBe("Bearer secret-token");
|
||||
await expect(client.getStructure(42, "9007199254740994", "secret-token", authority("u", 42, [ESI_ASSETS_SCOPE]))).rejects.toThrow("missing required scope");
|
||||
expect(ESI_READ_OPERATIONS.characterStructure.requiredScope).toBe(ESI_STRUCTURES_SCOPE);
|
||||
});
|
||||
|
||||
test("retries transient responses with Retry-After but does not retry 401/403", async () => {
|
||||
const delays: number[] = [];
|
||||
const retryable = queueFetch([
|
||||
@@ -366,13 +379,14 @@ describe("T06 transport bounds, authority, and metadata", () => {
|
||||
expect(bounded.getCacheStats().cacheEntries).toBeLessThanOrEqual(512);
|
||||
expect(bounded.getCacheStats(7 * 60 * 60 * 1000).cacheEntries).toBe(0);
|
||||
|
||||
const weightedBody = `[{\"item_id\":1,\"name\":\"${"x".repeat(900_000)}\"}]`;
|
||||
const weightedQueue = queueFetch(Array.from({ length: 40 }, () => response(weightedBody, { "Cache-Control": "max-age=60" })));
|
||||
const weightedBody = JSON.stringify(Array.from({ length: 1_000 }, (_, index) => ({ item_id: index + 1, name: "x".repeat(512) })));
|
||||
const weightedIds = Array.from({ length: 1_000 }, (_, index) => String(index + 1));
|
||||
const weightedQueue = queueFetch(Array.from({ length: 80 }, () => response(weightedBody, { "Cache-Control": "max-age=60" })));
|
||||
const weighted = new EsiClient({ userAgent: "test", fetch: weightedQueue.fetch, now: () => 0 });
|
||||
for (let index = 0; index < 40; index += 1) await weighted.getAssetNames(7, ["1"], "token", authority(`weighted-${index}`, 7));
|
||||
for (let index = 0; index < 80; index += 1) await weighted.getAssetNames(7, weightedIds, "token", authority(`weighted-${index}`, 7));
|
||||
const weightedStats = weighted.getCacheStats();
|
||||
expect(weightedStats.cacheBytes).toBeLessThanOrEqual(ESI_CACHE_MAX_BYTES);
|
||||
expect(weightedStats.cacheEntries).toBeLessThan(40);
|
||||
expect(weightedStats.cacheEntries).toBeLessThan(80);
|
||||
|
||||
const snapshotRows = Array.from({ length: 1_000 }, (_, index) => asset(String(index + 1)));
|
||||
const snapshotBody = JSON.stringify(snapshotRows);
|
||||
|
||||
+12
@@ -2,6 +2,17 @@
|
||||
"compatibilityDate": "2026-08-18",
|
||||
"evidence": {
|
||||
"assetHelpers": { "source": "docs/research/eve-openapi-evidence.json", "verification": "pinned-authoritative" },
|
||||
"privateStructure": {
|
||||
"source": "docs/research/eve-openapi-evidence.json",
|
||||
"verification": "pinned-authoritative",
|
||||
"operationId": "GetUniverseStructuresStructureId",
|
||||
"route": "/universe/structures/{structure_id}",
|
||||
"method": "GET",
|
||||
"requiredScope": "esi-universe.read_structures.v1",
|
||||
"response": { "status": 200, "mediaType": "application/json", "shape": "name/solar_system_id/owner_id with optional type_id/position" },
|
||||
"deniedStatus": 403,
|
||||
"cache": { "ttlSeconds": 3600, "policy": "ttl-based", "validators": ["Cache-Control", "ETag", "Last-Modified"] }
|
||||
},
|
||||
"walletBalance": {
|
||||
"source": "https://esi.evetech.net/meta/openapi.json?compatibility_date=2026-08-18",
|
||||
"verification": "current-retrieval-with-pinned-compatibility-date-not-historical-capture",
|
||||
@@ -28,6 +39,7 @@
|
||||
"operations": {
|
||||
"characterAssetNames": { "route": "/characters/{character_id}/assets/names", "method": "POST", "requiredScope": "esi-assets.read_assets.v1" },
|
||||
"characterAssetLocations": { "route": "/characters/{character_id}/assets/locations", "method": "POST", "requiredScope": "esi-assets.read_assets.v1" },
|
||||
"characterStructure": { "route": "/universe/structures/{structure_id}", "method": "GET", "requiredScope": "esi-universe.read_structures.v1" },
|
||||
"characterWalletBalance": { "route": "/characters/{character_id}/wallet", "method": "GET", "requiredScope": "esi-wallet.read_character_wallet.v1" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"itemId":"10","characterId":42,"typeId":100,"quantity":1,"locationId":"11","locationType":"item","locationFlag":"Cargo"},
|
||||
{"itemId":"11","characterId":42,"typeId":101,"quantity":1,"locationId":"10","locationType":"item","locationFlag":"AutoFit"},
|
||||
{"itemId":"12","characterId":42,"typeId":102,"quantity":1,"locationId":"99","locationType":"item","locationFlag":"SpecializedShipHold"}
|
||||
]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"itemId":"9007199254740993","characterId":42,"typeId":100,"quantity":1,"locationId":"60003760","locationType":"station","locationFlag":"Hangar","isSingleton":true},
|
||||
{"itemId":"9007199254740995","characterId":42,"typeId":101,"quantity":2,"locationId":"9007199254740993","locationType":"item","locationFlag":"Cargo","isSingleton":false},
|
||||
{"itemId":"9007199254740997","characterId":42,"typeId":102,"quantity":1,"locationId":"9007199254740995","locationType":"item","locationFlag":"HiSlot0","isSingleton":true}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveContainment, classifyFittedFlag } from '../src/lib/server/inventory/containment';
|
||||
import type { Asset } from '../src/lib/domain/model';
|
||||
|
||||
const nested = JSON.parse(readFileSync('tests/fixtures/inventory/nested-assets.json', 'utf8')) as Asset[];
|
||||
const cycles = JSON.parse(readFileSync('tests/fixtures/inventory/cycles-and-missing.json', 'utf8')) as Asset[];
|
||||
|
||||
describe('T08 containment graph', () => {
|
||||
test('resolves nested character-scoped decimal IDs deterministically', () => {
|
||||
const first = resolveContainment({
|
||||
assets: [...nested].reverse(),
|
||||
characterId: 42,
|
||||
names: new Map([['9007199254740993', 'Same name'], ['9007199254740995', 'Same name']]),
|
||||
typeNames: new Map([[102, 'Fitted module']]),
|
||||
});
|
||||
const second = resolveContainment({ assets: nested, characterId: 42, names: new Map([['9007199254740993', 'Same name'], ['9007199254740995', 'Same name']]), typeNames: new Map([[102, 'Fitted module']]) });
|
||||
expect(first.complete).toBe(true);
|
||||
expect(first.nodes.map((node) => node.itemId)).toEqual(['9007199254740993', '9007199254740995', '9007199254740997']);
|
||||
expect(first.nodes[2]).toMatchObject({ depth: 2, root: { kind: 'external', id: '60003760' }, fitted: { classification: 'fitted', rawFlag: 'HiSlot0' } });
|
||||
expect(first.nodes[2]?.breadcrumbs.map((entry) => entry.name)).toEqual(['Same name', 'Same name']);
|
||||
expect(first.nodes).toEqual(second.nodes);
|
||||
});
|
||||
|
||||
test('preserves ambiguous fit flags rather than guessing', () => {
|
||||
expect(classifyFittedFlag('AutoFit').classification).toBe('unknown');
|
||||
expect(classifyFittedFlag('SpecializedShipHold').classification).toBe('unknown');
|
||||
expect(classifyFittedFlag('Cargo').classification).toBe('unknown');
|
||||
expect(classifyFittedFlag('Hangar').classification).toBe('unknown');
|
||||
expect(classifyFittedFlag('LoSlot7').classification).toBe('fitted');
|
||||
});
|
||||
|
||||
test('reports cycles, missing parents, duplicate IDs and depth caps', () => {
|
||||
const result = resolveContainment({ assets: cycles, characterId: 42 });
|
||||
expect(result.complete).toBe(false);
|
||||
expect(result.nodes.find((node) => node.itemId === '10')?.unresolved).toContain('cycle');
|
||||
expect(result.nodes.find((node) => node.itemId === '11')?.unresolved).toContain('cycle');
|
||||
expect(result.nodes.find((node) => node.itemId === '12')?.unresolved).toContain('missing_parent');
|
||||
const duplicate = resolveContainment({ assets: [cycles[0]!, { ...cycles[0]!, typeId: 999 }], characterId: 42 });
|
||||
expect(duplicate.nodes.every((node) => node.unresolved.includes('duplicate_item_id'))).toBe(true);
|
||||
const capped = resolveContainment({ assets: nested, characterId: 42, maxDepth: 1 });
|
||||
expect(capped.nodes.find((node) => node.itemId === '9007199254740997')?.unresolved).toContain('depth_cap');
|
||||
});
|
||||
|
||||
test('does not infer an item parent from a non-item numeric location', () => {
|
||||
const result = resolveContainment({ assets: [{ ...nested[1]!, locationId: nested[0]!.itemId, locationType: 'station' }], characterId: 42 });
|
||||
expect(result.nodes[0]).toMatchObject({ parentItemId: null, root: { kind: 'external', id: nested[0]!.itemId } });
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds a valid 50k chain without recursive stack growth', () => {
|
||||
const deep = Array.from({ length: 50_000 }, (_, index) => ({
|
||||
itemId: String(index + 1), characterId: 42, typeId: 100, quantity: 1,
|
||||
locationId: index === 0 ? '60003760' : String(index), locationType: index === 0 ? 'station' : 'item', locationFlag: 'Cargo',
|
||||
})) as Asset[];
|
||||
const result = resolveContainment({ assets: deep, characterId: 42 });
|
||||
expect(result.nodes).toHaveLength(50_000);
|
||||
expect(result.complete).toBe(false);
|
||||
expect(result.nodes.at(-1)?.unresolved).toContain('depth_cap');
|
||||
});
|
||||
|
||||
test('returns safe unresolved nodes for null and invalid-ID input', () => {
|
||||
const result = resolveContainment({ assets: [null, { itemId: '0', locationId: 'not-an-id', characterId: 42, typeId: 1, quantity: 1, locationType: 'station', locationFlag: 'Hangar' }] as any, characterId: 42 });
|
||||
expect(result.nodes[0]?.unresolved).toEqual(expect.arrayContaining(['invalid_asset', 'invalid_item_id', 'invalid_location_id']));
|
||||
expect(result.nodes[1]?.unresolved).toEqual(expect.arrayContaining(['invalid_asset', 'invalid_item_id', 'invalid_location_id']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Asset } from '../src/lib/domain/model';
|
||||
import { createReferenceEnrichmentPin } from '../src/lib/domain/inventory-location';
|
||||
import { InventoryLocationService } from '../src/lib/server/inventory/location-service';
|
||||
import { EsiPrivateStructurePort, PrivateStructureCache, privateStructureCacheKey } from '../src/lib/server/inventory/private-structure-cache';
|
||||
import { EsiHttpError } from '../src/lib/server/esi';
|
||||
import type { ReferenceReaderPort } from '../src/lib/domain/reference';
|
||||
|
||||
const authority = { userId: 'user-a', principal: 'character:42', characterId: 42, scopes: ['esi-assets.read_assets.v1'], grantRevision: 3, authorizationEpoch: 2, credentialVersion: 4 } as const;
|
||||
const assets: Asset[] = [
|
||||
{ itemId: '9007199254740993', characterId: 42, typeId: 100, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar', isSingleton: true },
|
||||
{ itemId: '9007199254740995', characterId: 42, typeId: 101, quantity: 2, locationId: '9007199254740993', locationType: 'item', locationFlag: 'Cargo', isSingleton: false },
|
||||
{ itemId: '9007199254740997', characterId: 42, typeId: 102, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar', isSingleton: true },
|
||||
];
|
||||
|
||||
function reference(): ReferenceReaderPort {
|
||||
const reader: any = {
|
||||
pinnedVersion: 'ref-v1',
|
||||
async types() { return []; }, async version() { return 'ref-v1'; },
|
||||
async searchTypes() { return []; }, async searchTypesByName() { return []; }, async categories() { return []; }, async groups() { return []; },
|
||||
async regions(selection: any) { return selection.ids?.includes(10000002) ? [{ id: 10000002, name: 'The Forge' }] : []; },
|
||||
async constellations() { return []; },
|
||||
async systems(selection: any) { return selection.ids?.includes(30000142) ? [{ id: 30000142, name: 'Jita', constellationId: 20000020, regionId: 10000002, x: 0, y: 0, z: 0, securityStatus: 0.9, securityClass: 'highsec' }] : []; },
|
||||
async stations(selection: any) { return selection.ids?.includes(60003760) ? [{ id: 60003760, systemId: 30000142, systemName: 'Jita', regionId: 10000002, name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant', typeId: 1531, ownerId: 1000035, evidence: 'system_listed', sourceVerified: false, x: null, y: null, z: null }] : []; },
|
||||
pin() { return this; }, close() {},
|
||||
};
|
||||
return reader as ReferenceReaderPort;
|
||||
}
|
||||
|
||||
function namesObservation() {
|
||||
return { requestedIds: assets.map((asset) => asset.itemId), names: [{ itemId: assets[0]!.itemId, name: 'Same name' }, { itemId: assets[1]!.itemId, name: 'Same name' }], missing: [assets[2]!.itemId], retrievedAt: 100, expiresAt: 1_000, complete: false };
|
||||
}
|
||||
function locationsObservation() {
|
||||
return { requestedIds: assets.map((asset) => asset.itemId), locations: assets.map((asset, index) => ({ itemId: asset.itemId, position: { x: index, y: index === 0 ? 0 : -index, z: 0 } })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true };
|
||||
}
|
||||
|
||||
describe('T08 location enrichment seam', () => {
|
||||
test('pins authority, reference, helper freshness and system-listed evidence', async () => {
|
||||
let now = 200;
|
||||
const service = new InventoryLocationService({
|
||||
reference: reference(), now: () => now,
|
||||
structures: { async access(_context, structureId) { return { structureId, characterId: 42, status: 'accessible', evidence: 'esi_structure_access', name: 'Private Keep', observedAt: 100, expiresAt: 900, dockingPermission: 'unknown' }; } },
|
||||
});
|
||||
const result = await service.enrich({ assets, authority, assetGeneration: 'gen-7', names: namesObservation(), locations: locationsObservation(), structureRevision: 'structures-4' });
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.enrichmentVersion).toMatch(/^containment-v1-/);
|
||||
expect(result.inputPin).toMatchObject({ assetGeneration: 'gen-7', referenceVersion: 'ref-v1', structureRevision: 'structures-4', authority: { userId: 'user-a', characterId: 42, grantRevision: 3 } });
|
||||
expect(result.items[0]).toMatchObject({ customName: 'Same name', location: { kind: 'station', evidence: 'system_listed', sourceVerified: false, system: { name: 'Jita', region: { name: 'The Forge' } } } });
|
||||
expect(result.items[2]).toMatchObject({ location: { kind: 'structure', name: 'Private Keep', evidence: 'private_structure_access', dockingPermission: 'unknown', observedAt: 100, expiresAt: 900 } });
|
||||
expect(result.inputPin.structures).toEqual([{ structureId: assets[2]!.locationId, characterId: 42, status: 'accessible', evidence: 'esi_structure_access', name: 'Private Keep', solarSystemId: null, ownerId: null, typeId: null, observedAt: 100, expiresAt: 900, referenceVersion: 'ref-v1' }]);
|
||||
expect(result.items[0]?.position).toEqual({ x: 0, y: 0, z: 0 });
|
||||
expect(result.items[1]?.breadcrumbs.map((entry) => entry.name)).toEqual(['Same name']);
|
||||
now = 1_001;
|
||||
const stale = await service.enrich({ assets, authority, assetGeneration: 'gen-7', names: namesObservation(), locations: locationsObservation() });
|
||||
expect(stale.complete).toBe(false);
|
||||
expect(stale.items[0]?.nameStatus).toBe('expired');
|
||||
expect(stale.items[0]?.customName).toBeNull();
|
||||
expect(stale.items[0]?.position).toBeNull();
|
||||
expect(stale.unresolved.some((entry) => entry.reasons.includes('name_expired'))).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes a direct single helper batch into the generation pin', () => {
|
||||
const pin = createReferenceEnrichmentPin({ assetGeneration: 'single-batch', authority, referenceVersion: 'ref-v1', namesBatches: [namesObservation()], locationsBatches: [locationsObservation()] });
|
||||
expect(pin.names.requestedIds).toEqual(assets.map((asset) => asset.itemId).sort());
|
||||
expect(pin.locations.resolvedIds).toHaveLength(assets.length);
|
||||
});
|
||||
|
||||
test('keeps 50k-row enrichment indexed and bounded across helper batches', async () => {
|
||||
const largeAssets: Asset[] = Array.from({ length: 50_000 }, (_, index) => ({ itemId: String(index + 1), characterId: 42, typeId: 100, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }));
|
||||
const nameBatches = Array.from({ length: 50 }, (_, batch) => { const ids = largeAssets.slice(batch * 1_000, (batch + 1) * 1_000).map((asset) => asset.itemId); return { requestedIds: ids, names: [], missing: ids, retrievedAt: 100, expiresAt: 1_000, complete: false }; });
|
||||
const locationBatches = nameBatches.map((batch) => ({ requestedIds: batch.requestedIds, locations: [], missing: batch.requestedIds, retrievedAt: batch.retrievedAt, expiresAt: batch.expiresAt, complete: batch.complete }));
|
||||
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: largeAssets, authority, assetGeneration: 'large-generation', namesBatches: nameBatches, locationsBatches: locationBatches });
|
||||
expect(result.items).toHaveLength(50_000);
|
||||
expect(result.status).toBe('complete');
|
||||
expect(result.inputPin.names.requestedIds).toHaveLength(50_000);
|
||||
expect(result.inputPin.locations.requestedIds).toHaveLength(50_000);
|
||||
});
|
||||
|
||||
test('retains every bounded helper batch in one generation-level pin', async () => {
|
||||
const manyAssets: Asset[] = Array.from({ length: 1_001 }, (_, index) => ({ itemId: String(10_000 + index), characterId: 42, typeId: 100, quantity: 1, locationId: '30000142', locationType: 'solar_system', locationFlag: 'Hangar' }));
|
||||
const makeNames = (slice: Asset[]) => ({ requestedIds: slice.map((asset) => asset.itemId), names: slice.map((asset) => ({ itemId: asset.itemId, name: `Item ${asset.itemId}` })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true });
|
||||
const makeLocations = (slice: Asset[]) => ({ requestedIds: slice.map((asset) => asset.itemId), locations: slice.map((asset) => ({ itemId: asset.itemId, position: { x: 0, y: 0, z: 0 } })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true });
|
||||
const namesBatches = [makeNames(manyAssets.slice(0, 1_000)), makeNames(manyAssets.slice(1_000))];
|
||||
const locationsBatches = [makeLocations(manyAssets.slice(0, 1_000)), makeLocations(manyAssets.slice(1_000))];
|
||||
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: manyAssets, authority, assetGeneration: 'many-batches', namesBatches, locationsBatches });
|
||||
expect(result.items).toHaveLength(1_001);
|
||||
expect(result.items[1_000]?.customName).toBe('Item 11000');
|
||||
expect(result.inputPin.names.requestedIds).toHaveLength(1_001);
|
||||
expect(result.inputPin.locations.resolvedIds).toHaveLength(1_001);
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
test('retains resolved IDs when a later helper batch is forbidden or unavailable', async () => {
|
||||
const first = assets[0]!;
|
||||
const second = { ...assets[1]!, itemId: '9007199254740999', locationId: '30000142', locationType: 'solar_system' as const };
|
||||
const observedNames = { requestedIds: [first.itemId], names: [{ itemId: first.itemId, name: 'Observed' }], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true };
|
||||
const forbiddenNames = { state: 'forbidden' as const, requestedIds: [second.itemId], names: [], missing: [], retrievedAt: 200, expiresAt: 1_000, complete: false };
|
||||
const observedLocations = { requestedIds: [first.itemId], locations: [{ itemId: first.itemId, position: { x: 1, y: 2, z: 3 } }], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true };
|
||||
const unavailableLocations = { state: 'unavailable' as const, requestedIds: [second.itemId], locations: [], missing: [], retrievedAt: 200, expiresAt: 1_000, complete: false };
|
||||
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: [first, second], authority, assetGeneration: 'mixed-batches', namesBatches: [observedNames, forbiddenNames], locationsBatches: [observedLocations, unavailableLocations] });
|
||||
expect(result.inputPin.names).toMatchObject({ state: 'forbidden', resolvedIds: [first.itemId], batches: [{ state: 'observed' }, { state: 'forbidden' }] });
|
||||
expect(result.inputPin.locations).toMatchObject({ state: 'unavailable', resolvedIds: [first.itemId], batches: [{ state: 'observed' }, { state: 'unavailable' }] });
|
||||
expect(result.items[0]).toMatchObject({ customName: 'Observed', position: { x: 1, y: 2, z: 3 } });
|
||||
expect(result.items[1]).toMatchObject({ nameStatus: 'forbidden', locationStatus: 'unavailable' });
|
||||
expect(result.complete).toBe(false);
|
||||
});
|
||||
|
||||
test('distinguishes helper forbidden/unavailable and never fabricates names', async () => {
|
||||
const service = new InventoryLocationService({ reference: reference(), now: () => 100 });
|
||||
const forbidden = await service.enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'g1', namesState: 'forbidden', namesRequestedIds: [assets[0]!.itemId], locationsState: 'unavailable', locationsRequestedIds: [assets[0]!.itemId] });
|
||||
expect(forbidden.items[0]).toMatchObject({ customName: null, nameStatus: 'forbidden', locationStatus: 'unavailable', location: { name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant' } });
|
||||
expect(forbidden.unresolved[0]?.reasons).toEqual(expect.arrayContaining(['name_forbidden', 'location_unavailable']));
|
||||
});
|
||||
|
||||
test('forbidden structures are explicit and do not become docking ACL claims', async () => {
|
||||
const service = new InventoryLocationService({ reference: reference(), now: () => 100, structures: { async access(_context, structureId) { return { structureId, characterId: 42, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt: 100, expiresAt: 200, dockingPermission: 'unknown' }; } } });
|
||||
const result = await service.enrich({ assets: [assets[2]!], authority, assetGeneration: 'g2' });
|
||||
expect(result.complete).toBe(false);
|
||||
expect(result.items[0]?.location).toMatchObject({ kind: 'structure', evidence: 'unknown', name: null, dockingPermission: 'unknown' });
|
||||
expect(result.unresolved[0]?.reasons).toContain('forbidden_structure');
|
||||
expect(result.inputPin.structures).toEqual([{ structureId: assets[2]!.locationId, characterId: 42, status: 'forbidden', evidence: 'esi_forbidden', name: null, solarSystemId: null, ownerId: null, typeId: null, observedAt: 100, expiresAt: 200, referenceVersion: 'ref-v1' }]);
|
||||
});
|
||||
|
||||
test('not-requested observations never expose body data, and pins match rows', async () => {
|
||||
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({
|
||||
assets: assets.slice(0, 1), authority, assetGeneration: 'not-requested',
|
||||
names: { ...namesObservation(), state: 'not_requested' }, locations: { ...locationsObservation(), state: 'not_requested' },
|
||||
});
|
||||
expect(result.items[0]).toMatchObject({ customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested' });
|
||||
expect(result.inputPin.names).toMatchObject({ state: 'not_requested', resolvedIds: [] });
|
||||
expect(result.inputPin.locations).toMatchObject({ state: 'not_requested', resolvedIds: [] });
|
||||
});
|
||||
|
||||
test('oversized helper arrays reject before traversing elements', async () => {
|
||||
let nameReads = 0;
|
||||
const oversizedNames: any[] = new Array(1_001);
|
||||
Object.defineProperty(oversizedNames, 0, { enumerable: true, get() { nameReads += 1; return { itemId: assets[0]!.itemId, name: 'should-not-read' }; } });
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-names', names: { ...namesObservation(), names: oversizedNames } as any })).rejects.toThrow('exceeds');
|
||||
expect(nameReads).toBe(0);
|
||||
|
||||
let locationReads = 0;
|
||||
const oversizedLocations: any[] = new Array(1_001);
|
||||
Object.defineProperty(oversizedLocations, 0, { enumerable: true, get() { locationReads += 1; return { itemId: assets[0]!.itemId, position: { x: 0, y: 0, z: 0 } }; } });
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-locations', locations: { ...locationsObservation(), locations: oversizedLocations } as any })).rejects.toThrow('exceeds');
|
||||
expect(locationReads).toBe(0);
|
||||
|
||||
const oversizedRequested = new Array(1_001).fill('1');
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-requested', namesRequestedIds: oversizedRequested as any })).rejects.toThrow('namesRequestedIds exceeds');
|
||||
});
|
||||
|
||||
test('unknown and malformed helper states fail closed', async () => {
|
||||
const unknownNames = { ...namesObservation(), state: 'unknown' };
|
||||
const unknownLocations = { ...locationsObservation(), state: 'unknown' };
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-name-state', names: unknownNames as any })).rejects.toThrow('invalid enrichment observation state');
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-location-state', locations: unknownLocations as any })).rejects.toThrow('invalid enrichment observation state');
|
||||
const malformedNames = { ...namesObservation(), names: [{ itemId: assets[0]!.itemId, name: '' }] };
|
||||
const malformedLocations = { ...locationsObservation(), locations: locationsObservation().locations.map((entry, index) => index === 0 ? { ...entry, position: { x: Number.NaN, y: 0, z: 0 } } : entry) };
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-name', names: malformedNames as any })).rejects.toThrow('names observation contains invalid');
|
||||
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-position', locations: malformedLocations as any })).rejects.toThrow('locations observation contains invalid');
|
||||
});
|
||||
|
||||
test('requested IDs without observations are pinned unavailable, not not-requested', async () => {
|
||||
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'missing-observation', namesRequestedIds: [assets[0]!.itemId], locationsRequestedIds: [assets[0]!.itemId] });
|
||||
expect(result.items[0]).toMatchObject({ nameStatus: 'unavailable', locationStatus: 'unavailable', customName: null, position: null });
|
||||
expect(result.inputPin.names).toMatchObject({ state: 'unavailable', requestedIds: [assets[0]!.itemId] });
|
||||
expect(result.inputPin.locations).toMatchObject({ state: 'unavailable', requestedIds: [assets[0]!.itemId] });
|
||||
});
|
||||
|
||||
test('consolidates containment and helper failures to one unresolved record per item', async () => {
|
||||
const broken = { itemId: '1', characterId: 42, typeId: 1, quantity: 1, locationId: '99', locationType: 'item', locationFlag: 'Cargo' } as Asset;
|
||||
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: [broken], authority, assetGeneration: 'broken', namesRequestedIds: ['1'], locationsRequestedIds: ['1'] });
|
||||
expect(result.unresolved).toHaveLength(1);
|
||||
expect(result.unresolved[0]).toMatchObject({ itemId: '1', reasons: expect.arrayContaining(['missing_parent', 'name_unavailable', 'location_unavailable']) });
|
||||
});
|
||||
|
||||
test('closes an acquired reference handle when validation throws', async () => {
|
||||
let closes = 0;
|
||||
const owner: any = reference();
|
||||
const handle: any = { ...owner, pinnedVersion: 'ref-v1', close() { closes += 1; } };
|
||||
owner.pin = () => handle;
|
||||
const service = new InventoryLocationService({ reference: owner, now: () => 200 });
|
||||
await expect(service.enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'throws', maxDepth: 65 })).rejects.toThrow('containment depth');
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
|
||||
test('closes a malformed handle returned by pin()', async () => {
|
||||
let closes = 0;
|
||||
const owner: any = reference();
|
||||
const handle: any = { ...owner, pinnedVersion: undefined, close() { closes += 1; } };
|
||||
owner.pin = () => handle;
|
||||
await expect(new InventoryLocationService({ reference: owner, now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-pin' })).rejects.toThrow('reference pin did not provide a version');
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('T08 private structure cache', () => {
|
||||
test('typed ESI structure adapter preserves access evidence and maps only 403/404', async () => {
|
||||
const context = { ...authority, scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], referenceVersion: 'ref-v1' };
|
||||
const accessible = new EsiPrivateStructurePort({ getStructure: async () => ({ structureId: '8000000000000001', name: 'Keep', solarSystemId: '30000142', ownerId: '99000001', typeId: '35832', position: null, retrievedAt: 100, expiresAt: 500 }) } as any, 'token', () => 100);
|
||||
await expect(accessible.access(context, '8000000000000001')).resolves.toMatchObject({ status: 'accessible', evidence: 'esi_structure_access', solarSystemId: '30000142', dockingPermission: 'unknown' });
|
||||
const denied = new EsiPrivateStructurePort({ getStructure: async () => { throw new EsiHttpError(403, '/universe/structures/8000000000000001'); } } as any, 'token', () => 100);
|
||||
await expect(denied.access(context, '8000000000000001')).resolves.toMatchObject({ status: 'forbidden', evidence: 'esi_forbidden', name: null, dockingPermission: 'unknown' });
|
||||
const unavailable = new EsiPrivateStructurePort({ getStructure: async () => { throw new EsiHttpError(500, '/universe/structures/8000000000000001'); } } as any, 'token', () => 100);
|
||||
await expect(unavailable.access(context, '8000000000000001')).rejects.toBeInstanceOf(EsiHttpError);
|
||||
});
|
||||
|
||||
test('isolates user/character/revision/reference and expires with upstream', async () => {
|
||||
let now = 0;
|
||||
const cache = new PrivateStructureCache({ now: () => now, maxEntries: 4, maxBytes: 16_000 });
|
||||
const context = { ...authority, referenceVersion: 'ref-v1' };
|
||||
const other = { ...context, userId: 'user-b', principal: 'character:42' };
|
||||
const value = { structureId: '8000000000000001', characterId: 42, status: 'accessible' as const, evidence: 'esi_structure_access' as const, name: 'Keep', observedAt: 0, expiresAt: 100, dockingPermission: 'unknown' as const };
|
||||
expect(cache.set(context, value.structureId, value)).toBe(true);
|
||||
expect(cache.get(context, value.structureId)?.name).toBe('Keep');
|
||||
expect(cache.get(other, value.structureId)).toBeNull();
|
||||
expect(cache.get({ ...context, grantRevision: 4 }, value.structureId)).toBeNull();
|
||||
expect(cache.get({ ...context, referenceVersion: 'ref-v2' }, value.structureId)).toBeNull();
|
||||
expect(privateStructureCacheKey(context, value.structureId)).not.toContain('token');
|
||||
now = 100;
|
||||
expect(cache.get(context, value.structureId)).toBeNull();
|
||||
});
|
||||
|
||||
test('bounds entries and invalidates every authority revision', () => {
|
||||
let now = 0;
|
||||
const cache = new PrivateStructureCache({ now: () => now, maxEntries: 2, maxBytes: 16_000 });
|
||||
const context = { ...authority, referenceVersion: 'r' };
|
||||
const make = (id: string) => ({ structureId: id, characterId: 42, status: 'forbidden' as const, evidence: 'esi_forbidden' as const, name: null, observedAt: 0, expiresAt: 50, dockingPermission: 'unknown' as const });
|
||||
cache.set(context, '1', make('1')); cache.set({ ...context, grantRevision: 2 }, '2', make('2')); cache.set({ ...context, authorizationEpoch: 3 }, '3', make('3'));
|
||||
expect(cache.size).toBeLessThanOrEqual(2);
|
||||
expect(cache.invalidateAuthority(context)).toBeGreaterThan(0);
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { makeFunctionReference } from 'convex/server';
|
||||
import { convexTest } from 'convex-test';
|
||||
import schema from '../src/convex/schema';
|
||||
import { EsiRateLimitError } from '../src/lib/server/esi';
|
||||
import { createReferenceEnrichmentPin, deterministicEnrichmentVersion } from '../src/lib/domain/inventory-location';
|
||||
import { InventoryLocationService } from '../src/lib/server/inventory/location-service';
|
||||
import { processSyncJob } from '../workers/sync';
|
||||
|
||||
process.env.CONVEX_BRIDGE_SECRET = 'test-bridge-secret';
|
||||
@@ -163,6 +165,146 @@ describe('T07 durable asset scheduling', () => {
|
||||
await expect(t.mutation(mutation('bridgeFailSyncJob'), { ...worker, jobId: cancelled.jobId, leaseToken: cancelled.leaseToken })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('stages private enrichment with the exact generation and exposes an atomic read pin', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 726, sessionHash: 'enrichment-session' });
|
||||
await enqueue(t, account, 726, 'enrichment-session');
|
||||
const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'worker-enrichment' });
|
||||
const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken });
|
||||
const asset = { itemId: '1', characterId: 726, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' };
|
||||
await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] });
|
||||
const row = {
|
||||
...asset, parentItemId: null, root: { kind: 'external', id: '60003760', locationType: 'station', locationFlag: 'Hangar' }, depth: 0, breadcrumbs: [],
|
||||
fitted: { classification: 'unknown', rawFlag: 'Hangar', reason: 'ambiguous_special_bay' }, fittedClassification: 'unknown', isFitted: null,
|
||||
unresolved: [], customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested',
|
||||
location: { kind: 'station', id: '60003760', name: 'Jita', parentId: null, evidence: 'system_listed', sourceVerified: false, referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: null },
|
||||
};
|
||||
await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: [row] });
|
||||
const inputPin = createReferenceEnrichmentPin({ assetGeneration: generation, referenceVersion: 'ref-v1', authority: { userId: account.userId, principal: 'queue-owner', characterId: 726, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, referenceFacts: [{ kind: 'station', id: '60003760', name: 'Jita', parentId: null, evidence: 'system_listed', sourceVerified: false, systemId: null, regionId: null, referenceVersion: 'ref-v1' }] });
|
||||
await t.mutation(mutation('bridgeCommitSyncJob'), {
|
||||
...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000,
|
||||
enrichmentStatus: 'partial', enrichmentVersion: deterministicEnrichmentVersion(inputPin), enrichmentInputPin: inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: 0,
|
||||
});
|
||||
const page = await t.query(query('bridgeAssetsPage'), { ...bridge, userId: account.userId, characterId: 726, paginationOpts: { numItems: 10, cursor: null } });
|
||||
expect(page).toMatchObject({ generation, enrichmentStatus: 'partial', enrichmentVersion: deterministicEnrichmentVersion(inputPin) });
|
||||
const manifest = await t.query(query('bridgeEnrichmentManifest'), { ...bridge, userId: account.userId, characterId: 726, expectedGeneration: generation });
|
||||
expect(manifest).toMatchObject({ generation, status: 'partial', version: deterministicEnrichmentVersion(inputPin), unresolvedCount: 0 });
|
||||
const chunks = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 726, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null } });
|
||||
expect(chunks.chunks[0]?.rows[0]).toMatchObject({ itemId: '1', location: { dockingPermission: 'unknown' } });
|
||||
await expect(t.query(query('bridgeEnrichmentManifest'), { ...bridge, userId: 'other-user', characterId: 726, expectedGeneration: generation })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('enrichment first page reports the active generation after a rollover', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 728, sessionHash: 'enrichment-rollover-session' });
|
||||
await enqueue(t, account, 728, 'enrichment-rollover-session');
|
||||
const first = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'enrichment-rollover-worker-1' });
|
||||
const generationOne = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: first.jobId, leaseToken: first.leaseToken });
|
||||
await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: first.jobId, leaseToken: first.leaseToken, chunkCount: 0, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000 });
|
||||
await enqueue(t, account, 728, 'enrichment-rollover-session');
|
||||
const second = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'enrichment-rollover-worker-2' });
|
||||
const generationTwo = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: second.jobId, leaseToken: second.leaseToken });
|
||||
await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: second.jobId, leaseToken: second.leaseToken, chunkCount: 0, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000 });
|
||||
expect(generationTwo).not.toBe(generationOne);
|
||||
const page = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 728, expectedGeneration: generationOne, paginationOpts: { numItems: 10, cursor: null } });
|
||||
expect(page).toMatchObject({ chunks: [], generation: generationTwo, isDone: true });
|
||||
});
|
||||
|
||||
test('commit rejects forged public metadata even when the root ID is correct', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 727, sessionHash: 'forged-reference-facts-session' });
|
||||
await enqueue(t, account, 727, 'forged-reference-facts-session');
|
||||
const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'forged-reference-facts-worker' });
|
||||
const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken });
|
||||
const asset = { itemId: '2', characterId: 727, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' };
|
||||
await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] });
|
||||
const row = { ...asset, parentItemId: null, root: { kind: 'external', id: '60003760', locationType: 'station', locationFlag: 'Hangar' }, depth: 0, breadcrumbs: [], fitted: { classification: 'unknown', rawFlag: 'Hangar', reason: 'ambiguous_special_bay' }, fittedClassification: 'unknown', isFitted: null, unresolved: [], customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested', location: { kind: 'station', id: '60003760', name: 'Forged', parentId: null, evidence: 'system_listed', sourceVerified: false, referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: null } };
|
||||
await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: [row] });
|
||||
const inputPin = createReferenceEnrichmentPin({ assetGeneration: generation, referenceVersion: 'ref-v1', authority: { userId: account.userId, principal: 'queue-owner', characterId: 727, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, referenceFacts: [{ kind: 'station', id: '60003760', name: 'Jita', parentId: null, evidence: 'system_listed', sourceVerified: false, systemId: null, regionId: null, referenceVersion: 'ref-v1' }] });
|
||||
await expect(t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'partial', enrichmentVersion: deterministicEnrichmentVersion(inputPin), enrichmentInputPin: inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: 0 })).rejects.toThrow('Public location facts');
|
||||
});
|
||||
|
||||
test('private structure rows must match pinned source facts at commit', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const priorAllowlist = process.env.APP_CAPABILITY_ALLOWLIST;
|
||||
process.env.APP_CAPABILITY_ALLOWLIST = `${priorAllowlist ?? ''},inventory_locations`;
|
||||
const account = await login(t, { characterId: 731, sessionHash: 'private-facts-session', capability: 'inventory_locations', scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], requestedScopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'] });
|
||||
process.env.APP_CAPABILITY_ALLOWLIST = priorAllowlist;
|
||||
await enqueue(t, account, 731, 'private-facts-session');
|
||||
const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'private-facts-worker' });
|
||||
const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken });
|
||||
const asset = { itemId: '8000000000000001', characterId: 731, typeId: 34, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar' };
|
||||
await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] });
|
||||
const service = new InventoryLocationService({ reference: { pinnedVersion: 'ref-v1', pin() { return this; }, close() {}, async types() { return []; }, async systems() { return []; }, async regions() { return []; }, async stations() { return []; }, async constellations() { return []; }, async searchTypes() { return []; }, async searchTypesByName() { return []; } } as any, structures: { async access() { return { structureId: asset.locationId, characterId: 731, status: 'accessible', evidence: 'esi_structure_access', name: 'TRUSTED PRIVATE', observedAt: 100, expiresAt: 1_000, dockingPermission: 'unknown' }; } }, now: () => 200 });
|
||||
const authority = { userId: account.userId, principal: 'queue-owner', characterId: 731, scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 };
|
||||
const enrichment = await service.enrich({ assets: [asset], authority, assetGeneration: generation });
|
||||
const forged = structuredClone(enrichment.rows) as any[];
|
||||
forged[0] = { ...forged[0], location: { ...forged[0].location, name: 'FORGED PRIVATE' } };
|
||||
await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: forged });
|
||||
await expect(t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'partial', enrichmentVersion: enrichment.enrichmentVersion, enrichmentInputPin: enrichment.inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: enrichment.unresolved.length })).rejects.toThrow('Structure row facts');
|
||||
});
|
||||
|
||||
test('service output with forbidden structure evidence stages and commits as explicit partial enrichment', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 729, sessionHash: 'forbidden-persistence-session' });
|
||||
await enqueue(t, account, 729, 'forbidden-persistence-session');
|
||||
const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'forbidden-persistence-worker' });
|
||||
const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken });
|
||||
const asset = { itemId: '8000000000000001', characterId: 729, typeId: 34, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar' };
|
||||
await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: [asset] });
|
||||
const service = new InventoryLocationService({
|
||||
reference: { pinnedVersion: 'ref-v1', pin() { return this; }, close() {}, async types() { return []; }, async systems() { return []; }, async regions() { return []; }, async stations() { return []; }, async constellations() { return []; }, async searchTypes() { return []; }, async searchTypesByName() { return []; } } as any,
|
||||
structures: { async access() { return { structureId: asset.locationId, characterId: 729, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt: 100, expiresAt: 1_000, dockingPermission: 'unknown' }; } },
|
||||
now: () => 200,
|
||||
});
|
||||
const enrichment = await service.enrich({ assets: [asset], authority: { userId: account.userId, principal: 'queue-owner', characterId: 729, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, assetGeneration: generation });
|
||||
expect(enrichment.rows[0]?.location).toMatchObject({ kind: 'structure', evidence: 'unknown', name: null });
|
||||
expect(enrichment.rows[0]?.unresolved).toContain('forbidden_structure');
|
||||
await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: enrichment.rows });
|
||||
await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: enrichment.status, enrichmentVersion: enrichment.enrichmentVersion, enrichmentInputPin: enrichment.inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: enrichment.unresolved.length });
|
||||
const chunks = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 729, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null } });
|
||||
expect(chunks.chunks[0]?.rows[0]).toMatchObject({ location: { evidence: 'unknown', name: null }, unresolved: ['forbidden_structure'] });
|
||||
});
|
||||
|
||||
test('indexed persistence bounds a 2.5k raw generation without repeated full scans', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 750, sessionHash: 'large-persistence-session' });
|
||||
await enqueue(t, account, 750, 'large-persistence-session');
|
||||
const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'large-persistence-worker' });
|
||||
await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken });
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const assets = Array.from({ length: 250 }, (_, offset) => ({ itemId: String(index * 250 + offset + 1), characterId: 750, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }));
|
||||
await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index, assets });
|
||||
}
|
||||
await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 10, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'failed', enrichmentChunkCount: 0, enrichmentUnresolvedCount: 2_500, enrichmentError: 'bounded test fallback' });
|
||||
const page = await t.query(query('bridgeAssetsPage'), { ...bridge, userId: account.userId, characterId: 750, paginationOpts: { numItems: 1, cursor: null } });
|
||||
expect(page.page).toHaveLength(250);
|
||||
});
|
||||
|
||||
test('missing parents, unknown references, and helper misses persist as explicit partial rows', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 730, sessionHash: 'partial-row-session' });
|
||||
await enqueue(t, account, 730, 'partial-row-session');
|
||||
const claimed = await t.mutation(mutation('bridgeClaimSyncJob'), { ...worker, workerId: 'partial-row-worker' });
|
||||
const generation = await t.mutation(mutation('bridgeBeginSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken });
|
||||
const rows = [
|
||||
{ itemId: '100', characterId: 730, typeId: 34, quantity: 1, locationId: '999', locationType: 'item', locationFlag: 'Cargo' },
|
||||
{ itemId: '101', characterId: 730, typeId: 35, quantity: 1, locationId: '60009999', locationType: 'station', locationFlag: 'Hangar' },
|
||||
];
|
||||
await t.mutation(mutation('bridgeStageSyncAssets'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, assets: rows });
|
||||
const observation = { requestedIds: ['100'], names: [], missing: ['100'], retrievedAt: 100, expiresAt: 1_000, complete: false };
|
||||
const forbiddenObservation = { state: 'forbidden' as const, requestedIds: ['101'], names: [], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: false };
|
||||
const locationObservation = { requestedIds: ['100'], locations: [], missing: ['100'], retrievedAt: 100, expiresAt: 1_000, complete: false };
|
||||
const unavailableLocationObservation = { state: 'unavailable' as const, requestedIds: ['101'], locations: [], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: false };
|
||||
const service = new InventoryLocationService({ reference: { pinnedVersion: 'ref-v1', pin() { return this; }, close() {}, async types() { return []; }, async systems() { return []; }, async regions() { return []; }, async stations() { return []; }, async constellations() { return []; }, async searchTypes() { return []; }, async searchTypesByName() { return []; } } as any, now: () => 200 });
|
||||
const enrichment = await service.enrich({ assets: rows, authority: { userId: account.userId, principal: 'queue-owner', characterId: 730, scopes: ['esi-assets.read_assets.v1'], grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1 }, assetGeneration: generation, namesBatches: [observation, forbiddenObservation], locationsBatches: [locationObservation, unavailableLocationObservation] });
|
||||
expect(enrichment.rows.map((row) => row.unresolved).flat()).toEqual(expect.arrayContaining(['missing_parent', 'reference_unknown']));
|
||||
await t.mutation(mutation('bridgeStageSyncEnrichment'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, index: 0, rows: enrichment.rows });
|
||||
await t.mutation(mutation('bridgeCommitSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken, chunkCount: 1, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000, enrichmentStatus: 'partial', enrichmentVersion: enrichment.enrichmentVersion, enrichmentInputPin: enrichment.inputPin, enrichmentChunkCount: 1, enrichmentUnresolvedCount: enrichment.unresolved.length });
|
||||
const chunks = await t.query(query('bridgeEnrichmentChunksPage'), { ...bridge, userId: account.userId, characterId: 730, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null } });
|
||||
expect(chunks.chunks[0]?.rows.flatMap((row: any) => row.unresolved)).toEqual(expect.arrayContaining(['missing_parent', 'reference_unknown']));
|
||||
});
|
||||
|
||||
test('authorization quarantine fences both stale success and stale failure', async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const account = await login(t, { characterId: 727, sessionHash: 'quarantine-session' });
|
||||
@@ -174,6 +316,38 @@ describe('T07 durable asset scheduling', () => {
|
||||
await expect(t.mutation(mutation('bridgeFailSyncJob'), { ...worker, jobId: claimed.jobId, leaseToken: claimed.leaseToken })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('worker batches helper calls and publishes enrichment with the staged authority pin', async () => {
|
||||
const calls: string[] = [];
|
||||
const claimed = { id: 'job-location', jobId: 'job-location', userId: 'user-location', characterId: 730, leaseToken: 'lease-location', status: 'running', attempts: 1, dueAt: Date.now(), createdAt: Date.now(), updatedAt: Date.now(), grantRevision: 2, authorizationEpoch: 3, credentialVersion: 4, scopes: ['esi-assets.read_assets.v1'] } as any;
|
||||
const assets = Array.from({ length: 1_001 }, (_, index) => ({ itemId: String(index + 1), characterId: 730, typeId: 34, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }));
|
||||
const store = {
|
||||
claimSyncJob: async () => claimed,
|
||||
renewSyncJob: async () => true,
|
||||
acquireRefreshLease: async () => ({ leaseToken: 'refresh-location', expiresAt: Date.now() + 120_000, expectedVersion: 1, encryptedTokens: 'sealed', tokenExpiresAt: Date.now() + 120_000, scopes: claimed.scopes, grantRevision: 2, authorizationEpoch: 3, credentialVersion: 4 }),
|
||||
refreshSyncJobAuthority: async () => ({ grantRevision: 2, authorizationEpoch: 3, credentialVersion: 4, scopes: claimed.scopes }),
|
||||
beginSyncJob: async () => 'generation-location', cooldown: async () => null,
|
||||
stageSyncAssets: async () => {},
|
||||
stageSyncEnrichment: async (_job: string, _lease: string, index: number) => { calls.push(`enrichment:${index}`); },
|
||||
commitSyncJob: async (_job: string, _lease: string, _chunks: number, _synced: number, _expires: number, enrichment: any) => { calls.push(`commit:${enrichment.status}:${enrichment.chunkCount}`); return true; },
|
||||
failSyncJob: async () => true, deferSyncJob: async () => true,
|
||||
};
|
||||
const esi = {
|
||||
getAssets: async () => ({ assets, syncedAt: Date.now() - 1_000, expiresAt: Date.now() + 60_000 }),
|
||||
getAssetNames: async (_character: number, ids: string[]) => ({ requestedIds: ids, names: [], missing: ids, retrievedAt: 1, expiresAt: 60_000, complete: false }),
|
||||
getAssetLocations: async (_character: number, ids: string[]) => ({ requestedIds: ids, locations: [], missing: ids, retrievedAt: 1, expiresAt: 60_000, complete: false }),
|
||||
};
|
||||
let capturedNames = 0; let capturedLocations = 0;
|
||||
await processSyncJob({
|
||||
store: store as any,
|
||||
cipher: { open: async () => ({ characterId: 730, ownerHash: 'owner', accessToken: 'access', refreshToken: 'refresh', expiresAt: Date.now() + 120_000, scopes: claimed.scopes }) } as any,
|
||||
sso: {} as any, esi: esi as any, workerId: 'worker-location', pollMs: 1, leaseMs: 600_000, heartbeatMs: 60_000, sleep: async () => {},
|
||||
locationServiceFactory: () => ({ enrich: async (request: any) => { capturedNames = request.namesBatches.length; capturedLocations = request.locationsBatches.length; return { status: 'partial', rows: [], unresolved: [{ itemId: null, reasons: ['unavailable_structure'] }], enrichmentVersion: 'containment-v1-worker', inputPin: { assetGeneration: 'generation-location' } }; } } as any),
|
||||
});
|
||||
expect(capturedNames).toBe(2);
|
||||
expect(capturedLocations).toBe(2);
|
||||
expect(calls).toEqual(['commit:partial:0']);
|
||||
});
|
||||
|
||||
test('worker retries applied-then-lost staging and commit acknowledgements idempotently', async () => {
|
||||
const events: string[] = [];
|
||||
let stageCalls = 0; let commitCalls = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseSyncEnqueueBody } from '../src/lib/client/sync-queue';
|
||||
import { workerEnvironment } from '../scripts/run-sync-worker';
|
||||
import { prepareEnrichmentChunks } from '../workers/sync';
|
||||
|
||||
describe('T07 worker runtime boundary', () => {
|
||||
test('bundled worker starts with inert restricted configuration and has no unresolved aliases', async () => {
|
||||
@@ -24,6 +25,16 @@ describe('T07 worker runtime boundary', () => {
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('worker preflights oversized enrichment envelopes before any stage mutation', () => {
|
||||
const rows = Array.from({ length: 100 }, (_, index) => ({ itemId: String(index + 1), payload: 'x'.repeat(6_000) })) as any;
|
||||
const chunks = prepareEnrichmentChunks(rows);
|
||||
expect(chunks).not.toBeNull();
|
||||
expect(chunks!.length).toBeGreaterThan(1);
|
||||
expect(Math.max(...chunks!.map((chunk) => new TextEncoder().encode(JSON.stringify(chunk)).byteLength))).toBeLessThanOrEqual(256 * 1024);
|
||||
expect(prepareEnrichmentChunks([{ payload: 'x'.repeat(300_000) }] as any)).toBeNull();
|
||||
expect(prepareEnrichmentChunks(Array.from({ length: 2_500 }, () => ({ payload: 'x'.repeat(1_000) })) as any)).toBeNull();
|
||||
});
|
||||
|
||||
test('client preserves structured all-skipped and partial enqueue outcomes on HTTP errors', async () => {
|
||||
expect(parseSyncEnqueueBody({ queued: 0, jobs: [], skipped: [{ characterId: 1, reason: 'queue_full' }], partial: true })).toMatchObject({ queued: 0, skipped: [{ characterId: 1, reason: 'queue_full' }] });
|
||||
expect(parseSyncEnqueueBody({ queued: 1, jobs: [{ id: 'job', characterId: 2, status: 'queued', attempts: 0, dueAt: 1, createdAt: 1, updatedAt: 1, leaseExpiresAt: null, cancelRequested: false }], skipped: [{ characterId: 3, reason: 'enqueue_failed' }], partial: true })).toMatchObject({ queued: 1, jobs: [{ id: 'job', characterId: 2 }], skipped: [{ characterId: 3, reason: 'enqueue_failed' }] });
|
||||
@@ -56,8 +67,8 @@ describe('T07 worker runtime boundary', () => {
|
||||
});
|
||||
|
||||
test('worker child environment drops Infisical bootstrap credentials and ambient web keys', () => {
|
||||
const child = workerEnvironment({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret', INFISICAL_TOKEN: 'inert-token', INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'inert-client-id', INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'inert-client-secret', CONVEX_BRIDGE_SECRET: 'inert-bridge', APP_AUTH_PRIVATE_KEY: 'inert-jwt', TYPESAFE_API_KEY: 'inert-jev' });
|
||||
expect(child).toEqual({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret' });
|
||||
const child = workerEnvironment({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret', REFERENCE_DIR: '/app/data/reference', INFISICAL_TOKEN: 'inert-token', INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'inert-client-id', INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'inert-client-secret', CONVEX_BRIDGE_SECRET: 'inert-bridge', APP_AUTH_PRIVATE_KEY: 'inert-jwt', TYPESAFE_API_KEY: 'inert-jev' });
|
||||
expect(child).toEqual({ PATH: '/bin', CONVEX_URL: 'http://inert', CONVEX_SYNC_WORKER_SECRET: 'worker-only', TOKEN_ENCRYPTION_KEY: 'inert-key', EVE_CLIENT_ID: 'inert-client', EVE_CLIENT_SECRET: 'inert-eve-secret', REFERENCE_DIR: '/app/data/reference' });
|
||||
expect(Object.keys(child).some((key) => key.startsWith('INFISICAL_'))).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user