Files
eden/tests/wallet-balance.test.ts

312 lines
25 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { ESI_WALLET_SCOPE, EsiError, EsiRateLimitError } from '../src/lib/server/esi';
import { InMemoryWalletBalanceJobQueue, InMemoryWalletBalancePublicationPort, WalletBalanceJobAdapter } from '../src/lib/server/wallet/balance-job';
import { InMemoryWalletBalanceRepository } from '../src/lib/server/wallet/balance-repository';
import { WalletBalanceService } from '../src/lib/server/wallet/balance-service';
import { credential, fence, resource, snapshot } from './fixtures/wallet';
describe('wallet balance core', () => {
test('sums signed and larger-than-safe-number cents exactly, without wealth/assets', async () => {
const one = resource('tenant-a', 1);
const two = resource('tenant-a', 2);
const repository = new InMemoryWalletBalanceRepository({
resources: [one, two],
snapshots: [snapshot(one, '-900719925474099301'), snapshot(two, '900719925474099301')],
});
const result = await new WalletBalanceService({ repository, now: () => 500 }).read({ userId: 'tenant-a' });
expect(result.status).toBe('ready');
expect(result.walletTotal).toBe('0');
expect(result.walletOnlyTotal).toBe('0');
expect(result.components.every((component) => component.component === 'wallet_balance')).toBe(true);
expect(result.provenance?.input.requiredDatasetKinds).toEqual(['wallet_balance']);
expect(result.provenance?.input.referenceVersion).toBeNull();
});
test('shows consent/stale state per character and does not produce a partial total', async () => {
const granted = resource('tenant-a', 1);
const noConsent = resource('tenant-a', 2, { consent: 'missing', grantedScopes: [], credentialVersion: null });
const stale = resource('tenant-a', 3);
const queue = new InMemoryWalletBalanceJobQueue();
const repository = new InMemoryWalletBalanceRepository({ resources: [granted, noConsent, stale], snapshots: [snapshot(granted, '125'), snapshot(stale, '-25', 100, 200)] });
const result = await new WalletBalanceService({ repository, queue, now: () => 500 }).read({ userId: 'tenant-a' });
expect(result.status).toBe('partial');
expect(result.walletTotal).toBeNull();
expect(result.characters.map((character) => [character.characterId, character.consent, character.freshness])).toEqual([[1, 'granted', 'fresh'], [2, 'missing', 'consent_required'], [3, 'granted', 'stale']]);
expect(result.missing.map((principal) => principal.id)).toEqual(['2', '3']);
expect(result.refresh.queued).toBe(1);
expect((await queue.claim('worker', 500))?.characterId).toBe(3);
});
test('opening Wallet does not enqueue a valid cache and queue deduplicates stale refreshes', async () => {
const value = resource('tenant-a', 4);
const queue = new InMemoryWalletBalanceJobQueue();
const repository = new InMemoryWalletBalanceRepository({ resources: [value], snapshots: [snapshot(value, '4', 100, 200)] });
const service = new WalletBalanceService({ repository, queue, now: () => 500 });
const first = await service.read({ userId: 'tenant-a' });
const second = await service.read({ userId: 'tenant-a' });
expect(first.refresh.queued).toBe(1);
expect(second.refresh.duplicate).toBe(1);
const valid = await new WalletBalanceService({ repository, queue: new InMemoryWalletBalanceJobQueue(), now: () => 150 }).read({ userId: 'tenant-a' });
expect(valid.status).toBe('ready');
expect(valid.refresh.attempted).toBe(0);
queue.setCooldown('tenant-a', 4, 10_000);
const cooldown = await service.read({ userId: 'tenant-a' });
expect(cooldown.refresh.cooldown).toBe(1);
});
test('tenant and selector isolation is server resolved', async () => {
const a = resource('tenant-a', 7);
const b = resource('tenant-b', 7);
const repository = new InMemoryWalletBalanceRepository({ resources: [a, b], snapshots: [snapshot(a, '700'), snapshot(b, '900')] });
const result = await new WalletBalanceService({ repository, now: () => 500 }).read({ userId: 'tenant-a', characterIds: ['7'] });
expect(result.status).toBe('ready');
expect(result.walletTotal).toBe('700');
await expect(new WalletBalanceService({ repository, now: () => 500 }).read({ userId: 'tenant-a', characterIds: ['8'] })).resolves.toMatchObject({ status: 'blocked' });
expect(await repository.snapshot('tenant-b', 7)).toMatchObject({ amount: '900' });
});
test('job uses exact ESI transport and publication fence, preserving old snapshot on failure', async () => {
const value = resource('tenant-a', 8);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], snapshots: [snapshot(value, '10', 100, 200)], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ leaseMs: 100, now: () => 500 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
const observations = [{ amount: '-900719925474099301', retrievedAt: 500, expiresAt: 1_000 }];
const adapter = new WalletBalanceJobAdapter({
jobs, authority: repository, publication,
transport: { getWalletBalance: async (characterId, token, key) => { expect(characterId).toBe(8); expect(token).toBe('token-tenant-a-8'); expect(key.scopes).toContain(ESI_WALLET_SCOPE); return observations.shift()!; } },
now: () => 500,
});
const enqueued = await jobs.enqueue({ userId: 'tenant-a', characterId: 8, fence: fence(value), now: 500 });
expect(enqueued.status).toBe('queued');
const run = await adapter.runNext('worker-a');
expect(run.status).toBe('published');
expect((await repository.snapshot('tenant-a', 8))?.amount).toBe('-900719925474099301');
const failed = resource('tenant-a', 9);
const failedRepo = new InMemoryWalletBalanceRepository({ resources: [failed], snapshots: [snapshot(failed, '99', 100, 200)], credentials: [credential(failed)] });
const failedJobs = new InMemoryWalletBalanceJobQueue({ now: () => 500 });
const failedPublication = new InMemoryWalletBalancePublicationPort(failedJobs, failedRepo);
const failing = new WalletBalanceJobAdapter({ jobs: failedJobs, authority: failedRepo, publication: failedPublication, transport: { getWalletBalance: async () => { throw new Error('upstream unavailable'); } }, now: () => 500 });
await failedJobs.enqueue({ userId: 'tenant-a', characterId: 9, fence: fence(failed), now: 500 });
expect((await failing.runNext('worker-a')).status).toBe('failed');
expect((await failedRepo.snapshot('tenant-a', 9))?.amount).toBe('99');
expect((await failedRepo.failure('tenant-a', 9))?.category).toBe('refresh_failed');
});
test('borrowed or stale workers are fenced before publication', async () => {
const value = resource('tenant-a', 11);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ now: () => 100 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
const adapter = new WalletBalanceJobAdapter({ jobs, authority: repository, publication, transport: { getWalletBalance: async () => ({ amount: '1', retrievedAt: 100, expiresAt: 1_000 }) }, now: () => 100 });
await jobs.enqueue({ userId: 'tenant-a', characterId: 11, fence: fence(value), now: 100 });
const claim = await jobs.claim('worker-a', 100);
expect(claim).not.toBeNull();
const rotated = resource('tenant-a', 11, { grantRevision: 2 });
repository.replaceResource(rotated);
const rejected = await adapter.runClaim(claim!);
expect(rejected).toMatchObject({ status: 'rejected', reason: 'stale_grant' });
expect(await repository.snapshot('tenant-a', 11)).toBeNull();
});
test('transient authority lookup is deferred, not quarantined as revoked', async () => {
const value = resource('tenant-a', 12);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ now: () => 500 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
let transportCalls = 0;
const adapter = new WalletBalanceJobAdapter({ jobs, authority: { currentAuthority: async () => { throw new Error('backend timeout'); }, credential: repository.credential.bind(repository) }, publication, transport: { getWalletBalance: async () => { transportCalls += 1; return { amount: '1', retrievedAt: 500, expiresAt: 1_000 }; } }, now: () => 500 });
const enqueued = await jobs.enqueue({ userId: 'tenant-a', characterId: 12, fence: fence(value), now: 500 });
const result = await adapter.runNext('worker');
expect(result).toMatchObject({ status: 'unavailable' });
expect((await jobs.get(enqueued.status === 'queued' ? enqueued.job.id : ''))?.status).toBe('queued');
expect(transportCalls).toBe(0);
});
test('mutable tenant/character claim fields cannot substitute the canonical stored claim', async () => {
const value = resource('tenant-a', 13);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ now: () => 100 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
let authorityUser = '';
const authority = { currentAuthority: async (userId: string, characterId: number) => { authorityUser = userId; return repository.currentAuthority(userId, characterId); }, credential: repository.credential.bind(repository) };
const adapter = new WalletBalanceJobAdapter({ jobs, authority, publication, transport: { getWalletBalance: async () => ({ amount: '1', retrievedAt: 100, expiresAt: 1_000 }) }, now: () => 100 });
await jobs.enqueue({ userId: 'tenant-a', characterId: 13, fence: fence(value), now: 100 });
const claim = await jobs.claim('worker', 100);
const forged = { ...claim!, owner: { kind: 'user' as const, userId: 'tenant-b' }, fence: { ...claim!.fence, userId: 'tenant-b', principal: { ...claim!.fence.principal, ownerUserId: 'tenant-b' } } };
const result = await adapter.runClaim(forged);
expect(result.status).toBe('published');
expect(authorityUser).toBe('tenant-a');
expect((await repository.snapshot('tenant-a', 13))?.amount).toBe('1');
expect(await repository.snapshot('tenant-b', 13)).toBeNull();
});
test('publication requires exact snapshot fence pins and monotonic failure ordering', async () => {
const value = resource('tenant-a', 14);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], snapshots: [snapshot(value, '10', 200, 1_000)] });
const current = fence(value);
const failure = { userId: 'tenant-a', characterId: 14, failedAt: 300, category: 'upstream', retryable: true, authorizationRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'wallet-failure-new', jobId: 'failure-new' };
expect(await repository.publishFailure({ jobId: 'failure-new', workerId: 'worker', leaseToken: 'lease', fence: current, failure, publishedAt: 300 })).toMatchObject({ accepted: true });
const mismatched = snapshot(value, '11', 400, 1_000, 'wallet-success-mismatch');
mismatched.authorizationRevision = 2;
expect(await repository.publishSnapshot({ jobId: 'success-mismatch', workerId: 'worker', leaseToken: 'lease', fence: current, snapshot: mismatched, publishedAt: 400 })).toMatchObject({ accepted: false, reason: 'mismatched_character' });
expect(await repository.publishSnapshot({ jobId: 'success-old', workerId: 'worker', leaseToken: 'lease', fence: current, snapshot: snapshot(value, '11', 250, 1_000, 'wallet-success-old'), publishedAt: 400 })).toMatchObject({ accepted: false, reason: 'stale_generation' });
const oldFailure = { ...failure, failedAt: 250, generation: 'wallet-failure-old', jobId: 'failure-old' };
expect(await repository.publishFailure({ jobId: 'failure-old', workerId: 'worker', leaseToken: 'lease', fence: current, failure: oldFailure, publishedAt: 400 })).toMatchObject({ accepted: false, reason: 'stale_generation' });
expect((await repository.failure('tenant-a', 14))?.generation).toBe('wallet-failure-new');
});
test('publication uses the queue clock, so forged backdated timestamps cannot revive an expired lease', async () => {
const value = resource('tenant-a', 15);
const repository = new InMemoryWalletBalanceRepository({ resources: [value] });
const jobs = new InMemoryWalletBalanceJobQueue({ leaseMs: 10, now: () => 200 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
await jobs.enqueue({ userId: 'tenant-a', characterId: 15, fence: fence(value), now: 100 });
const claim = await jobs.claim('worker', 100);
expect(claim).not.toBeNull();
expect(await publication.publishSuccess({ jobId: claim!.id, workerId: claim!.workerId, leaseToken: claim!.leaseToken, fence: claim!.fence, snapshot: snapshot(value, '1', 100, 1_000), publishedAt: 100 })).toMatchObject({ accepted: false, reason: 'expired_job' });
});
test('rate-limit cooldown is stored and non-retryable failures suppress automatic enqueue', async () => {
const limited = resource('tenant-a', 16);
const limitedRepository = new InMemoryWalletBalanceRepository({ resources: [limited], credentials: [credential(limited)] });
const limitedJobs = new InMemoryWalletBalanceJobQueue({ now: () => 500 });
const limitedPublication = new InMemoryWalletBalancePublicationPort(limitedJobs, limitedRepository);
const limitedAdapter = new WalletBalanceJobAdapter({ jobs: limitedJobs, authority: limitedRepository, publication: limitedPublication, transport: { getWalletBalance: async () => { throw new EsiRateLimitError('/wallet', 1_000, 500, 'wallet-group'); } }, now: () => 500 });
await limitedJobs.enqueue({ userId: 'tenant-a', characterId: 16, fence: fence(limited), now: 500 });
expect((await limitedAdapter.runNext('worker')).status).toBe('failed');
expect(await limitedJobs.cooldown('tenant-a', 16, 500)).toMatchObject({ until: 1_000, group: 'wallet-group' });
const denied = resource('tenant-a', 17);
const deniedRepository = new InMemoryWalletBalanceRepository({ resources: [denied], credentials: [credential(denied)] });
const deniedJobs = new InMemoryWalletBalanceJobQueue({ now: () => 500 });
const deniedPublication = new InMemoryWalletBalancePublicationPort(deniedJobs, deniedRepository);
const deniedAdapter = new WalletBalanceJobAdapter({ jobs: deniedJobs, authority: deniedRepository, publication: deniedPublication, transport: { getWalletBalance: async () => { throw new EsiError('authorization_context', 'denied'); } }, now: () => 500 });
await deniedJobs.enqueue({ userId: 'tenant-a', characterId: 17, fence: fence(denied), now: 500 });
await deniedAdapter.runNext('worker');
const deniedView = await new WalletBalanceService({ repository: deniedRepository, queue: deniedJobs, now: () => 500 }).read({ userId: 'tenant-a' });
expect(deniedView.refresh.attempted).toBe(0);
});
test('lease heartbeat renews long transport and lost renewal forbids publication', async () => {
let clock = 100;
const value = resource('tenant-a', 18);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ leaseMs: 30, now: () => clock });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
let release!: () => void;
const adapter = new WalletBalanceJobAdapter({ jobs, authority: repository, publication, heartbeatMs: 5, now: () => clock, transport: { getWalletBalance: async () => { await new Promise<void>((resolve) => { release = resolve; }); return { amount: '18', retrievedAt: 100, expiresAt: 1_000 }; } } });
const enqueued = await jobs.enqueue({ userId: 'tenant-a', characterId: 18, fence: fence(value), now: 100 });
const running = adapter.runNext('worker');
await Bun.sleep(8);
clock = 120;
await Bun.sleep(12);
expect((await jobs.get(enqueued.status === 'queued' ? enqueued.job.id : ''))?.leaseExpiresAt).toBeGreaterThan(130);
release();
expect((await running).status).toBe('published');
const lost = resource('tenant-a', 19);
const lostRepository = new InMemoryWalletBalanceRepository({ resources: [lost], credentials: [credential(lost)] });
const lostJobs = new InMemoryWalletBalanceJobQueue({ leaseMs: 30, now: () => 100 });
const lostPublication = new InMemoryWalletBalancePublicationPort(lostJobs, lostRepository);
const originalRenew = lostJobs.renew.bind(lostJobs);
let renewals = 0;
lostJobs.renew = async (claim, now, leaseMs) => { renewals += 1; if (renewals > 1) return false; return originalRenew(claim, now, leaseMs); };
const lostAdapter = new WalletBalanceJobAdapter({ jobs: lostJobs, authority: lostRepository, publication: lostPublication, heartbeatMs: 5, now: () => 100, transport: { getWalletBalance: async () => { await Bun.sleep(20); return { amount: '19', retrievedAt: 100, expiresAt: 1_000 }; } } });
await lostJobs.enqueue({ userId: 'tenant-a', characterId: 19, fence: fence(lost), now: 100 });
expect((await lostAdapter.runNext('worker')).status).toBe('rejected');
expect(await lostRepository.snapshot('tenant-a', 19)).toBeNull();
});
test('raw wallet amounts and selector cardinality are bounded before arithmetic/traversal', async () => {
const value = resource('tenant-a', 20);
expect(() => new InMemoryWalletBalanceRepository({ resources: [value], snapshots: [snapshot(value, '-0')] })).toThrow();
const repository = new InMemoryWalletBalanceRepository({ resources: [value] });
await expect(new WalletBalanceService({ repository, now: () => 100 }).read({ userId: 'tenant-a', characterIds: Array.from({ length: 101 }, (_, index) => String(index + 1)) })).rejects.toThrow('cap');
});
test('service rejects malicious persisted future snapshot provenance', async () => {
const value = resource('tenant-a', 23);
const repository = new InMemoryWalletBalanceRepository({ resources: [value] });
repository.snapshot = async () => snapshot(value, '23', 1_000, 2_000, 'future-generation');
await expect(new WalletBalanceService({ repository, now: () => 500 }).read({ userId: 'tenant-a' })).rejects.toThrow('future');
});
test('malformed required scopes fail before any refresh enqueue', async () => {
const value = resource('tenant-a', 25);
const repository = new InMemoryWalletBalanceRepository({ resources: [value] });
repository.authorizedResources = async () => [{ ...value, resource: { ...value.resource, requiredScopes: [ESI_WALLET_SCOPE, null as unknown as string] } }];
const queue = new InMemoryWalletBalanceJobQueue({ now: () => 100 });
let enqueues = 0;
queue.enqueue = async () => { enqueues += 1; throw new Error('must not enqueue'); };
await expect(new WalletBalanceService({ repository, queue, now: () => 100 }).read({ userId: 'tenant-a' })).rejects.toThrow('scopes');
expect(enqueues).toBe(0);
});
test('malformed credential scopes and future transport observations fail closed', async () => {
const value = resource('tenant-a', 21);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ now: () => 100 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
const bad = credential(value).credential;
const authority = { currentAuthority: repository.currentAuthority.bind(repository), credential: async () => ({ ...bad, authorizationKey: { ...bad.authorizationKey, scopes: [ESI_WALLET_SCOPE, ESI_WALLET_SCOPE] } }) };
const badAdapter = new WalletBalanceJobAdapter({ jobs, authority, publication, now: () => 100, transport: { getWalletBalance: async () => ({ amount: '1', retrievedAt: 100, expiresAt: 1_000 }) } });
const badEnqueued = await jobs.enqueue({ userId: 'tenant-a', characterId: 21, fence: fence(value), now: 100 });
expect((await badAdapter.runNext('worker')).status).toBe('rejected');
expect((await jobs.get(badEnqueued.status === 'queued' ? badEnqueued.job.id : ''))?.status).toBe('quarantined');
const future = resource('tenant-a', 22);
const futureRepository = new InMemoryWalletBalanceRepository({ resources: [future], credentials: [credential(future)] });
const futureJobs = new InMemoryWalletBalanceJobQueue({ now: () => 100 });
const futurePublication = new InMemoryWalletBalancePublicationPort(futureJobs, futureRepository);
const futureAdapter = new WalletBalanceJobAdapter({ jobs: futureJobs, authority: futureRepository, publication: futurePublication, now: () => 100, transport: { getWalletBalance: async () => ({ amount: '1', retrievedAt: 101, expiresAt: 1_000 }) } });
await futureJobs.enqueue({ userId: 'tenant-a', characterId: 22, fence: fence(future), now: 100 });
expect((await futureAdapter.runNext('worker')).status).toBe('failed');
expect(await futureRepository.snapshot('tenant-a', 22)).toBeNull();
});
test('malformed accepted publication pairing is rejected without storing data', async () => {
const value = resource('tenant-a', 24);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ now: () => 100 });
const malformedPublication = {
publishSuccess: async () => ({ accepted: true, outcome: 'failure', snapshot: snapshot(value, '24', 100, 1_000) } as never),
publishFailure: async () => ({ accepted: true, outcome: 'success', snapshot: null } as never),
};
const adapter = new WalletBalanceJobAdapter({ jobs, authority: repository, publication: malformedPublication, now: () => 100, transport: { getWalletBalance: async () => ({ amount: '24', retrievedAt: 100, expiresAt: 1_000 }) } });
const enqueued = await jobs.enqueue({ userId: 'tenant-a', characterId: 24, fence: fence(value), now: 100 });
expect((await adapter.runNext('worker')).status).toBe('unavailable');
expect(await repository.snapshot('tenant-a', 24)).toBeNull();
expect((await jobs.get(enqueued.status === 'queued' ? enqueued.job.id : ''))?.status).toBe('queued');
});
test('duplicate and expired publication cannot overwrite a newer snapshot', async () => {
const value = resource('tenant-a', 10);
const repository = new InMemoryWalletBalanceRepository({ resources: [value], credentials: [credential(value)] });
const jobs = new InMemoryWalletBalanceJobQueue({ leaseMs: 10, now: () => 100 });
const publication = new InMemoryWalletBalancePublicationPort(jobs, repository);
const adapter = new WalletBalanceJobAdapter({ jobs, authority: repository, publication, transport: { getWalletBalance: async () => ({ amount: '1', retrievedAt: 100, expiresAt: 1_000 }) }, now: () => 100 });
const queued = await jobs.enqueue({ userId: 'tenant-a', characterId: 10, fence: fence(value), now: 100 });
expect(queued.status).toBe('queued');
const claim = await jobs.claim('worker', 100);
expect(claim).not.toBeNull();
const first = await adapter.runClaim(claim!);
expect(first.status).toBe('published');
// A second publication for the same job is rejected by the completed lease.
if (first.status === 'published') {
const duplicate = await publication.publishSuccess({ jobId: first.job.id, workerId: 'worker', leaseToken: claim!.leaseToken, fence: fence(value), snapshot: snapshot(value, '999', 101, 2_000, 'wallet-duplicate'), publishedAt: 101 });
expect(duplicate).toMatchObject({ accepted: false, reason: 'expired_job' });
const directDuplicate = await repository.publishSnapshot({ jobId: first.job.id, workerId: 'worker', leaseToken: 'bypassed', fence: fence(value), snapshot: snapshot(value, '999', 101, 2_000, 'wallet-duplicate'), publishedAt: 101 });
expect(directDuplicate).toMatchObject({ accepted: false, reason: 'duplicate_publication' });
const directOutcomeChange = await repository.publishFailure({ jobId: first.job.id, workerId: 'worker', leaseToken: 'bypassed', fence: fence(value), failure: { userId: value.userId, characterId: value.characterId, failedAt: 101, category: 'changed-outcome', retryable: true, authorizationRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'wallet-outcome-change', jobId: first.job.id }, publishedAt: 101 });
expect(directOutcomeChange).toMatchObject({ accepted: false, reason: 'duplicate_publication' });
}
const old = await repository.publishSnapshot({ jobId: 'old', workerId: 'worker', leaseToken: 'none', fence: fence(value), snapshot: snapshot(value, '0', 99, 1_000, 'wallet-old'), publishedAt: 101 });
expect(old).toMatchObject({ accepted: false, reason: 'stale_generation' });
const stored = await repository.snapshot('tenant-a', 10);
expect(stored).not.toBeNull();
const retry = await repository.publishSnapshot({ jobId: 'retry-identical', workerId: 'worker', leaseToken: 'none', fence: fence(value), snapshot: stored!, publishedAt: 101 });
expect(retry).toMatchObject({ accepted: true, outcome: 'success' });
});
});