176 lines
7.8 KiB
TypeScript
176 lines
7.8 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],
|
|
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();
|
|
});
|