Files
eden/tests/domain.test.ts

182 lines
8.1 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { AssetService } from '../src/lib/domain/assets';
import { ESI_ASSETS_SCOPE, manufacturingCapabilityGate } from '../src/lib/domain/capabilities';
import { addIsk, multiplyIsk, sumIsk, validateQuantity } from '../src/lib/domain/money';
import { distanceLightYears, EVE_METERS_PER_LIGHT_YEAR } from '../src/lib/domain/routing';
import type {
Character,
CharacterAssetRepository,
MarketEstimates,
ReferenceRepository,
} from '../src/lib/domain/model';
const now = 1_000;
function character(id: number, overrides: Partial<Character> = {}): Character {
return {
id,
userId: 'user-a',
name: `Pilot ${id}`,
scopes: [ESI_ASSETS_SCOPE],
loginRole: 'primary',
authorizationStatus: 'active',
grantRevision: 1,
credentialVersion: 1,
authorizationEpoch: 1,
datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'fixture', syncedAt: 900 }],
assets: { status: 'ready', syncedAt: 900, expiresAt: 2_000 },
...overrides,
};
}
function ports(
characters: Character[],
assets: Record<number, unknown[]> = {},
market: unknown = { prices: [], source: 'fixture', asOf: 900, expiresAt: 2_000 },
) {
let assetCalls = 0;
const repository: CharacterAssetRepository = {
characters: async () => characters,
assets: async (_userId, id) => {
assetCalls += 1;
return (assets[id] ?? []) as never[];
},
};
const reference: ReferenceRepository = {
types: async (ids) => ids.map((id) => ({ id, name: `Type ${id}`, categoryId: 1 })),
version: async () => 'fixture-v1',
};
const markets: MarketEstimates = { estimates: async () => market as never };
return { repository, reference, markets, get assetCalls() { return assetCalls; } };
}
const asset = (characterId: number, itemId: string, typeId: number, quantity: number, isBlueprintCopy?: boolean) => ({
characterId,
itemId,
typeId,
quantity,
locationId: '60000001',
locationType: 'station',
locationFlag: 'hangar',
...(isBlueprintCopy === undefined ? {} : { isBlueprintCopy }),
});
describe('money domain helpers', () => {
test('uses integer cents and BigInt accumulation', () => {
expect(addIsk('100', '25')).toBe('125');
expect(sumIsk(['9007199254740992', '8'])).toBe('9007199254741000');
expect(multiplyIsk('125', 3)).toBe('375');
});
test('rejects unsafe, fractional, and negative inventory quantities', () => {
expect(() => validateQuantity(Number.MAX_SAFE_INTEGER + 1)).toThrow();
expect(() => validateQuantity(1.5)).toThrow();
expect(() => validateQuantity(-1)).toThrow();
});
});
describe('AssetService', () => {
test('returns an auth gate before touching lazy repositories', async () => {
const service = new AssetService(
{ characters: async () => { throw new Error('must not run'); }, assets: async () => { throw new Error('must not run'); } },
{ version: async () => { throw new Error('must not run'); }, types: async () => { throw new Error('must not run'); } },
{ estimates: async () => { throw new Error('must not run'); } },
now,
);
const result = await service.report(null);
expect(result.status).toBe('authentication_required');
});
test('a logged-in user with no characters must connect, not receive a zero valuation', async () => {
const p = ports([]);
const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a');
expect(result).toMatchObject({ status: 'requirements_missing', missing: [{ requirement: 'assets', reason: 'authorization' }] });
expect(p.assetCalls).toBe(0);
});
test('duplicate instance IDs in one character snapshot cannot inflate totals', async () => {
const row = asset(1, '123', 34, 2);
const p = ports([character(1)], { 1: [row, row] });
expect(await new AssetService(p.repository, p.reference, p.markets, now).report('user-a')).toMatchObject({ status: 'unavailable' });
});
test('requires the exact assets scope and refuses partial character coverage', async () => {
const p = ports([
character(1),
character(2, { scopes: ['esi-assets.read_assets.v1.extra'], assets: { status: 'syncing' } }),
]);
const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a');
expect(result.status).toBe('requirements_missing');
if (result.status === 'requirements_missing') {
expect(result.missing).toEqual([
{ requirement: 'assets', characterId: 2, reason: 'authorization' },
{ requirement: 'assets', characterId: 2, reason: 'synchronization' },
]);
}
expect(p.assetCalls).toBe(0);
});
test('rejects stale snapshots rather than aggregating ready characters only', async () => {
const p = ports([character(1), character(2, { assets: { status: 'ready', syncedAt: 100, expiresAt: 1_000 } })]);
const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a');
expect(result.status).toBe('requirements_missing');
if (result.status === 'requirements_missing') {
expect(result.missing).toContainEqual({ requirement: 'assets', characterId: 2, reason: 'stale' });
}
});
test('aggregates characters and explicitly excludes unknown, missing-price, and BPC rows', async () => {
const p = ports(
[character(2), character(1)],
{
1: [asset(1, '2', 1, 2), asset(1, '3', 99, 4)],
2: [asset(2, '1', 2, 3), asset(2, '4', 3, 1, true)],
},
{ prices: [{ typeId: 1, unitPrice: '125' }, { typeId: 2, unitPrice: '300' }], source: 'fixture', asOf: 900, expiresAt: 2_000 },
);
p.reference.types = async (ids) => ids.filter((id) => id !== 99).map((id) => ({ id, name: `Type ${id}`, categoryId: 1 }));
const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a');
expect(result.status).toBe('ready');
if (result.status === 'ready') {
expect(result.report.total).toBe('1150');
expect(result.report.items.map((item) => item.exclusion)).toEqual([undefined, 'unknown_type', undefined, 'blueprint_copy']);
expect(result.report.unpricedItems).toBe(2);
expect(result.report.prices.source).toBe('fixture');
}
});
test('fails closed for stale and malformed market data', async () => {
const stale = ports([character(1)], { 1: [asset(1, '1', 1, 1)] }, { prices: [{ typeId: 1, unitPrice: '1' }], source: 'fixture', asOf: 0, expiresAt: now });
const staleResult = await new AssetService(stale.repository, stale.reference, stale.markets, now).report('user-a');
expect(staleResult).toMatchObject({ status: 'requirements_missing', missing: [{ requirement: 'market_data', reason: 'stale' }] });
const malformed = ports([character(1)], { 1: [asset(1, '1', 1, 1)] }, { prices: [{ typeId: 1, unitPrice: '-1' }], source: 'fixture', asOf: 900, expiresAt: 2_000 });
const malformedResult = await new AssetService(malformed.repository, malformed.reference, malformed.markets, now).report('user-a');
expect(malformedResult).toMatchObject({ status: 'unavailable' });
});
test('rejects cross-user character data', async () => {
const p = ports([character(1, { userId: 'other-user' })]);
const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a');
expect(result).toMatchObject({ status: 'unavailable' });
});
});
test('manufacturing gate names unimplemented skills and blueprints', () => {
expect(manufacturingCapabilityGate('user-a')).toEqual({
status: 'requirements_missing',
capability: 'manufacturing_opportunities',
missing: [
{ requirement: 'skills', reason: 'not_implemented' },
{ requirement: 'blueprints', reason: 'not_implemented' },
],
actions: [],
});
});
test('routing uses the exact EVE light-year constant and finite XYZ', () => {
expect(EVE_METERS_PER_LIGHT_YEAR).toBe(9_460_000_000_000_000);
expect(distanceLightYears({ systemId: 1, x: 0, y: 0, z: 0 }, { systemId: 2, x: EVE_METERS_PER_LIGHT_YEAR, y: 0, z: 0 })).toBe(1);
expect(() => distanceLightYears({ systemId: 1, x: Number.NaN, y: 0, z: 0 }, { systemId: 2, x: 0, y: 0, z: 0 })).toThrow();
});