Skip to content
Merged
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 @@ -250,3 +250,83 @@ describe('ClaimResolutionService.resolveClaim', () => {
expect(result.claim.resolvedAt).not.toBeNull();
});
});

// ------------------------------------------------------------------ //
// 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
53 changes: 49 additions & 4 deletions src/claims/claims.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,10 @@ describe('ClaimsService', () => {
const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null, resolvedAt: 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, resolvedAt: 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,7 +361,7 @@ 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');
Expand All @@ -329,7 +374,7 @@ describe('ClaimsService', () => {
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);

Expand Down
10 changes: 9 additions & 1 deletion src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { Stake } from '../staking/entities/stake.entity';
import { AuditTrailService } from '../audit/services/audit-trail.service';
import { AuditActionType, AuditEntityType } from '../audit/entities/audit-log.entity';
import { AuditLog } from '../audit/decorators/audit-log.decorator';
import {
assertResolvedAtInvariant,
buildResolvedFields,
} from './claim-resolution.invariant';


@Injectable()
Expand Down Expand Up @@ -127,7 +131,7 @@ export class ClaimsService {
userId?: string,
): Promise<Claim> {
const claim = await this.findOne(claimId);
if (!claim) throw new Error(`Claim ${claimId} not found`);
if (!claim) throw new NotFoundException(`Claim ${claimId} not found`);

const beforeState = { ...claim };

Expand All @@ -138,6 +142,10 @@ export class ClaimsService {
confidence: confidenceScore,
});

// Guard: reject if the object is somehow in an inconsistent state
// before we write (e.g. caller mutated fields directly).
assertResolvedAtInvariant(claim);

const updatedClaim = await this.claimRepo.save(claim);
// Invalidate both the claim-specific cache and the latest claims list cache
await this.claimsCache.invalidateClaim(claimId);
Expand Down
Loading
Loading