Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/claims/claim-resolution.invariant.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { BadRequestException } from '@nestjs/common';
import {
assertResolvedAtInvariant,
buildResolvedFields,
buildUnresolvedFields,
} from './claim-resolution.invariant';

/**
* Unit tests for the resolvedAt/resolvedVerdict invariant (issue #BE-219).
*
* Invariant: resolvedVerdict IS NOT NULL <=> resolvedAt IS NOT NULL
*/
describe('claim-resolution invariant (BE-219)', () => {
// ------------------------------------------------------------------ //
// assertResolvedAtInvariant //
// ------------------------------------------------------------------ //
describe('assertResolvedAtInvariant', () => {
it('passes when both fields are null (unresolved claim)', () => {
expect(() =>
assertResolvedAtInvariant({ resolvedVerdict: null, resolvedAt: null }),
).not.toThrow();
});

it('passes when both fields are set (resolved claim)', () => {
expect(() =>
assertResolvedAtInvariant({
resolvedVerdict: true,
resolvedAt: new Date(),
}),
).not.toThrow();

expect(() =>
assertResolvedAtInvariant({
resolvedVerdict: false,
resolvedAt: new Date(),
}),
).not.toThrow();
});

it('throws when resolvedVerdict is set but resolvedAt is null (bug scenario)', () => {
expect(() =>
assertResolvedAtInvariant({ resolvedVerdict: true, resolvedAt: null }),
).toThrow(BadRequestException);

expect(() =>
assertResolvedAtInvariant({ resolvedVerdict: false, resolvedAt: null }),
).toThrow(BadRequestException);
});

it('includes BE-219 in the error message for the verdict-without-timestamp case', () => {
expect(() =>
assertResolvedAtInvariant({ resolvedVerdict: true, resolvedAt: null }),
).toThrow(/BE-219/);
});

it('throws when resolvedAt is set but resolvedVerdict is null (inverse bug)', () => {
expect(() =>
assertResolvedAtInvariant({ resolvedVerdict: null, resolvedAt: new Date() }),
).toThrow(BadRequestException);
});

it('includes BE-219 in the error message for the timestamp-without-verdict case', () => {
expect(() =>
assertResolvedAtInvariant({ resolvedVerdict: null, resolvedAt: new Date() }),
).toThrow(/BE-219/);
});
});

// ------------------------------------------------------------------ //
// buildResolvedFields //
// ------------------------------------------------------------------ //
describe('buildResolvedFields', () => {
it('returns verdict=true and a non-null Date', () => {
const fields = buildResolvedFields(true);
expect(fields.resolvedVerdict).toBe(true);
expect(fields.resolvedAt).toBeInstanceOf(Date);
expect(fields.resolvedAt).not.toBeNull();
});

it('returns verdict=false and a non-null Date', () => {
const fields = buildResolvedFields(false);
expect(fields.resolvedVerdict).toBe(false);
expect(fields.resolvedAt).toBeInstanceOf(Date);
});

it('uses the provided date when supplied', () => {
const fixedDate = new Date('2026-01-01T12:00:00Z');
const fields = buildResolvedFields(true, fixedDate);
expect(fields.resolvedAt).toBe(fixedDate);
});

it('always passes assertResolvedAtInvariant', () => {
const fields = buildResolvedFields(true);
expect(() => assertResolvedAtInvariant(fields)).not.toThrow();
});

it('result fields are never undefined', () => {
const fields = buildResolvedFields(false);
expect(fields.resolvedVerdict).not.toBeUndefined();
expect(fields.resolvedAt).not.toBeUndefined();
});
});

// ------------------------------------------------------------------ //
// buildUnresolvedFields //
// ------------------------------------------------------------------ //
describe('buildUnresolvedFields', () => {
it('returns both fields as null', () => {
const fields = buildUnresolvedFields();
expect(fields.resolvedVerdict).toBeNull();
expect(fields.resolvedAt).toBeNull();
});

it('always passes assertResolvedAtInvariant', () => {
const fields = buildUnresolvedFields();
expect(() => assertResolvedAtInvariant(fields)).not.toThrow();
});
});
});
54 changes: 54 additions & 0 deletions src/claims/claim-resolution.invariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { BadRequestException } from '@nestjs/common';
import { Claim } from './entities/claim.entity';

/**
* Protocol invariant for Claim resolution state (issue #BE-219).
*
* The invariant is:
* resolvedVerdict IS NOT NULL <=> resolvedAt IS NOT NULL
*
* i.e. a claim is resolved if and only if both fields are set together.
* A claim must never be persisted in either of these inconsistent states:
* (a) resolvedVerdict is set but resolvedAt is null → "resolved without timestamp"
* (b) resolvedAt is set but resolvedVerdict is null → "timestamp without verdict"
*/
export function assertResolvedAtInvariant(claim: Pick<Claim, 'resolvedVerdict' | 'resolvedAt'>): void {
const hasVerdict = claim.resolvedVerdict !== null && claim.resolvedVerdict !== undefined;
const hasTimestamp = claim.resolvedAt !== null && claim.resolvedAt !== undefined;

if (hasVerdict && !hasTimestamp) {
throw new BadRequestException(
'Claim invariant violation: resolvedVerdict is set but resolvedAt is null. ' +
'Both must be set atomically when resolving a claim. (BE-219)',
);
}

if (!hasVerdict && hasTimestamp) {
throw new BadRequestException(
'Claim invariant violation: resolvedAt is set but resolvedVerdict is null. ' +
'resolvedAt must only be set when a verdict is also provided. (BE-219)',
);
}
}

/**
* Build the resolution fields that should be written atomically.
* Always use this when marking a claim as resolved — never assign
* resolvedVerdict or resolvedAt individually at call sites.
*/
export function buildResolvedFields(verdict: boolean, now: Date = new Date()): {
resolvedVerdict: boolean;
resolvedAt: Date;
} {
return { resolvedVerdict: verdict, resolvedAt: now };
}

/**
* Build the fields that clear resolution state (e.g. re-opening a claim).
*/
export function buildUnresolvedFields(): {
resolvedVerdict: null;
resolvedAt: null;
} {
return { resolvedVerdict: null, resolvedAt: null };
}
80 changes: 80 additions & 0 deletions src/claims/claim-resolution.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,83 @@ describe('Confidence Scoring', () => {
expect(score).toBe(0);
});
});

// ------------------------------------------------------------------ //
// resolveClaim — resolvedAt invariant (BE-219) //
// ------------------------------------------------------------------ //
describe('ClaimResolutionService.resolveClaim — resolvedAt invariant (BE-219)', () => {
function makeService() {
// Minimal mock repo and cache
const mockClaim = {
id: 'claim-001',
resolvedVerdict: null,
resolvedAt: null,
confidenceScore: null,
finalized: false,
};

const mockRepo: any = {
findOneBy: jest.fn().mockResolvedValue({ ...mockClaim }),
save: jest.fn().mockImplementation(async (c: any) => ({ ...c })),
};

const mockCache: any = {
invalidateClaim: jest.fn().mockResolvedValue(undefined),
};

return { service: new ClaimResolutionService(mockRepo, mockCache), mockRepo, mockCache };
}

it('sets resolvedAt to a non-null Date when a claim is resolved (BE-219)', async () => {
const { service } = makeService();
const before = Date.now();

const result = await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 });

const after = Date.now();
expect(result.resolvedAt).not.toBeNull();
expect(result.resolvedAt).toBeInstanceOf(Date);
expect((result.resolvedAt as Date).getTime()).toBeGreaterThanOrEqual(before);
expect((result.resolvedAt as Date).getTime()).toBeLessThanOrEqual(after);
});

it('sets resolvedAt and resolvedVerdict atomically (BE-219)', async () => {
const { service, mockRepo } = makeService();
const savedArgs: any[] = [];
mockRepo.save.mockImplementation(async (c: any) => {
savedArgs.push({ resolvedVerdict: c.resolvedVerdict, resolvedAt: c.resolvedAt });
return c;
});

await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 });

expect(savedArgs).toHaveLength(1);
expect(savedArgs[0].resolvedVerdict).not.toBeNull();
expect(savedArgs[0].resolvedAt).not.toBeNull();
});

it('sets finalized = true along with resolvedAt (BE-219)', async () => {
const { service } = makeService();
const result = await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 });
expect(result.finalized).toBe(true);
expect(result.resolvedAt).not.toBeNull();
});

it('throws when claim is not found', async () => {
const { service, mockRepo } = makeService();
mockRepo.findOneBy.mockResolvedValue(null);
await expect(service.resolveClaim('bad-id', { trueWeight: 100, falseWeight: 50 })).rejects.toThrow('Claim not found');
});

it('resolvedVerdict=true when trueWeight > falseWeight', async () => {
const { service } = makeService();
const result = await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 });
expect(result.resolvedVerdict).toBe(true);
});

it('resolvedVerdict=false when falseWeight > trueWeight', async () => {
const { service } = makeService();
const result = await service.resolveClaim('claim-001', { trueWeight: 50, falseWeight: 150 });
expect(result.resolvedVerdict).toBe(false);
});
});
4 changes: 4 additions & 0 deletions src/claims/claim-resolution.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { Repository, DataSource } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Claim, ClaimState } from './entities/claim.entity';
import { ClaimsCache } from '../cache/claims.cache';
import {
assertResolvedAtInvariant,
buildResolvedFields,
} from './claim-resolution.invariant';

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down
79 changes: 72 additions & 7 deletions src/claims/claims.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,13 @@ describe('ClaimsService', () => {

describe('resolveClaim', () => {
it('should resolve a claim with verdict and confidence score', async () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null });
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null });
const verdict = true;
const confidenceScore = 0.85;
const resolvedAt = new Date();

jest.spyOn(service, 'findOne').mockResolvedValue(claim);
jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, confidenceScore });
jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, resolvedAt, confidenceScore });
jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined);
jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined);

Expand All @@ -301,13 +302,57 @@ describe('ClaimsService', () => {
expect(result.confidenceScore).toEqual(confidenceScore);
});

it('sets resolvedAt to a non-null timestamp when resolving a claim (BE-219)', async () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null });
const before = Date.now();

jest.spyOn(service, 'findOne').mockResolvedValue(claim);
jest.spyOn(claimRepo, 'save').mockImplementation(async (c: any) => c);
jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined);
jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined);

const result = await service.resolveClaim(claim.id, true, 0.9);
const after = Date.now();

expect(result.resolvedAt).not.toBeNull();
expect(result.resolvedAt).toBeInstanceOf(Date);
expect((result.resolvedAt as Date).getTime()).toBeGreaterThanOrEqual(before);
expect((result.resolvedAt as Date).getTime()).toBeLessThanOrEqual(after);
});

it('sets resolvedAt and resolvedVerdict atomically (never one without the other, BE-219)', async () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null });
const savedStates: Partial<Claim>[] = [];

jest.spyOn(service, 'findOne').mockResolvedValue(claim);
jest.spyOn(claimRepo, 'save').mockImplementation(async (c: any) => {
savedStates.push({ resolvedVerdict: c.resolvedVerdict, resolvedAt: c.resolvedAt });
return c;
});
jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined);
jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined);

await service.resolveClaim(claim.id, false, 0.75);

// The object passed to save must have both fields set together
expect(savedStates).toHaveLength(1);
expect(savedStates[0].resolvedVerdict).not.toBeNull();
expect(savedStates[0].resolvedAt).not.toBeNull();
});

it('a non-resolved claim always has resolvedAt == null (BE-219)', () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null, finalized: false });
expect(claim.resolvedVerdict).toBeNull();
expect(claim.resolvedAt).toBeNull();
});

it('should invalidate claims:latest cache when resolving a claim', async () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null });
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null });
const verdict = false;
const confidenceScore = 0.65;

jest.spyOn(service, 'findOne').mockResolvedValue(claim);
jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, confidenceScore });
jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, resolvedAt: new Date(), confidenceScore });
jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined);
jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined);

Expand All @@ -316,27 +361,47 @@ describe('ClaimsService', () => {
expect(claimsCache.invalidateClaim).toHaveBeenCalledWith(claim.id);
});

it('should throw error if claim not found when resolving', async () => {
it('should throw NotFoundException if claim not found when resolving', async () => {
jest.spyOn(service, 'findOne').mockResolvedValue(null);

await expect(service.resolveClaim('non-existent-id', true, 0.8)).rejects.toThrow('Claim non-existent-id not found');
});

it('should log audit trail when resolving a claim', async () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null });
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null });
const verdict = true;
const confidenceScore = 0.75;
const userId = 'user-123';

jest.spyOn(service, 'findOne').mockResolvedValue(claim);
jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, confidenceScore });
jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, resolvedAt: new Date(), confidenceScore });
jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined);
jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined);

await service.resolveClaim(claim.id, verdict, confidenceScore, userId);

expect(auditTrailService.log).toHaveBeenCalled();
});

it('idempotency: resolving an already-resolved claim overwrites resolvedAt with the new timestamp', async () => {
// Policy: re-resolving is allowed (no error); resolvedAt is updated to the new call time.
// This matches the current codebase behaviour where no duplicate-resolve guard exists.
const previousDate = new Date('2026-01-01T00:00:00Z');
const claim = ClaimFactory.createClaim({ resolvedVerdict: true, resolvedAt: previousDate, confidenceScore: 0.8 });

jest.spyOn(service, 'findOne').mockResolvedValue(claim);
jest.spyOn(claimRepo, 'save').mockImplementation(async (c: any) => c);
jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined);
jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined);

const result = await service.resolveClaim(claim.id, false, 0.9);

// resolvedAt should be a new, more recent timestamp
expect(result.resolvedAt).not.toBeNull();
expect((result.resolvedAt as Date).getTime()).toBeGreaterThanOrEqual(previousDate.getTime());
// resolvedVerdict is updated to the new value
expect(result.resolvedVerdict).toBe(false);
});
});

describe('finalizeClaim', () => {
Expand Down
Loading
Loading