diff --git a/src/controllers/proofController.ts b/src/controllers/proofController.ts index 37ffc02..8966410 100644 --- a/src/controllers/proofController.ts +++ b/src/controllers/proofController.ts @@ -16,6 +16,7 @@ import { enqueueVerification } from '../workers/verificationWorker.js'; import { claimCompletionSlot } from '../models/task.js'; import logger from '../utils/logger.js'; import { cleanupUploadedFiles } from '../middleware/upload.js'; +import { getProofTaskApprovalBlockReason } from '../utils/proofApproval.js'; type SubmissionEligibility = | { status: 404 | 400 | 403 | 409; error: string } @@ -39,7 +40,10 @@ async function checkSubmissionEligibility( if (!task) { return { status: 404, error: 'task not found' }; } - if (task.status !== 'ACTIVE') { + if ( + task.status !== 'ACTIVE' || + (task.expiresAt != null && task.expiresAt.getTime() <= Date.now()) + ) { return { status: 400, error: 'task is not active' }; } @@ -308,7 +312,14 @@ export async function reviewProof(req: Request, res: Response) { const proof = await prisma.proof.findUnique({ where: { id: req.params.id }, - select: { id: true, userId: true, taskId: true, status: true }, + select: { + id: true, + userId: true, + taskId: true, + status: true, + createdAt: true, + task: { select: { status: true, expiresAt: true } }, + }, }); if (!proof) { return res.status(404).json({ error: 'proof not found' }); @@ -325,12 +336,22 @@ export async function reviewProof(req: Request, res: Response) { let notes = parsed.data.notes; if (requestedStatus === 'APPROVED') { - const slot = await claimCompletionSlot(tx, proof.taskId); - if (!slot.claimed) { + const approvalBlockReason = getProofTaskApprovalBlockReason( + proof.createdAt, + proof.task, + ); + if (approvalBlockReason) { finalStatus = 'REJECTED'; - notes = `${notes ?? ''} [auto-rejected: task reached max completions]`.trim(); + notes = `${notes ?? ''} [auto-rejected: ${approvalBlockReason}]`.trim(); } else { - taskCompleted = slot.taskCompleted; + const slot = await claimCompletionSlot(tx, proof.taskId, proof.createdAt); + if (!slot.claimed) { + finalStatus = 'REJECTED'; + notes = + `${notes ?? ''} [auto-rejected: task unavailable or at capacity]`.trim(); + } else { + taskCompleted = slot.taskCompleted; + } } } diff --git a/src/models/task.ts b/src/models/task.ts index fd39b1b..aaa01d5 100644 --- a/src/models/task.ts +++ b/src/models/task.ts @@ -126,16 +126,21 @@ export type SlotClaimResult = * Atomically claims one completion slot on a task, transitioning it to * COMPLETED if this was the last slot. Must be called inside the same * transaction that sets a proof's status to APPROVED. The UPDATE's WHERE - * clause (status ACTIVE AND completedCount < maxCompletions) and the row - * lock Postgres takes during the UPDATE make this race-free: concurrent - * callers serialize on this row, and a caller that loses the race gets - * zero rows back instead of a stale count. + * clause enforces capacity and the proof-submission deadline. EXPIRED tasks + * accept only proofs submitted by their deadline, so asynchronous review + * latency does not invalidate legitimate work. The row lock Postgres takes + * during the UPDATE makes capacity claims race-free. */ -type SlotRow = { completed_count: number; max_completions: number | null; status: string }; +type SlotRow = { + completed_count: number; + max_completions: number | null; + status: string; +}; export async function claimCompletionSlot( tx: Prisma.TransactionClient, taskId: string, + proofSubmittedAt: Date, ): Promise { const rows = await tx.$queryRaw` UPDATE tasks @@ -144,7 +149,12 @@ export async function claimCompletionSlot( WHEN max_completions IS NOT NULL AND completed_count + 1 >= max_completions THEN 'COMPLETED' ELSE status END - WHERE id = ${taskId} AND status = 'ACTIVE' + WHERE id = ${taskId} + AND ( + status = 'ACTIVE' + OR (status = 'EXPIRED' AND expires_at IS NOT NULL) + ) + AND (expires_at IS NULL OR ${proofSubmittedAt} <= expires_at) AND (max_completions IS NULL OR completed_count < max_completions) RETURNING completed_count, max_completions, status `; diff --git a/src/services/validatorService.ts b/src/services/validatorService.ts index d4f8ca5..5379879 100644 --- a/src/services/validatorService.ts +++ b/src/services/validatorService.ts @@ -3,6 +3,7 @@ import config from '../config/default.js'; import logger from '../utils/logger.js'; import { notifyProofStatus } from './notificationService.js'; import { claimCompletionSlot } from '../models/task.js'; +import { getProofTaskApprovalBlockReason } from '../utils/proofApproval.js'; const AUTO_VERIFIER_ID = 'quorum'; @@ -171,7 +172,12 @@ async function finalizeProof( const result = await prisma.$transaction(async (tx) => { const proofRow = await tx.proof.findUnique({ where: { id: proofId }, - select: { taskId: true, status: true }, + select: { + taskId: true, + status: true, + createdAt: true, + task: { select: { status: true, expiresAt: true } }, + }, }); if (!proofRow) return null; if (proofRow.status === 'APPROVED' || proofRow.status === 'REJECTED') { @@ -183,12 +189,21 @@ async function finalizeProof( let notes = `quorum of ${agreement.length} validators`; if (requestedStatus === 'APPROVED') { - const slot = await claimCompletionSlot(tx, proofRow.taskId); - if (!slot.claimed) { + const approvalBlockReason = getProofTaskApprovalBlockReason( + proofRow.createdAt, + proofRow.task, + ); + if (approvalBlockReason) { finalStatus = 'REJECTED'; - notes += ' [auto-rejected: task reached max completions]'; + notes += ` [auto-rejected: ${approvalBlockReason}]`; } else { - taskCompleted = slot.taskCompleted; + const slot = await claimCompletionSlot(tx, proofRow.taskId, proofRow.createdAt); + if (!slot.claimed) { + finalStatus = 'REJECTED'; + notes += ' [auto-rejected: task unavailable or at capacity]'; + } else { + taskCompleted = slot.taskCompleted; + } } } diff --git a/src/services/verificationService.ts b/src/services/verificationService.ts index e0abb5a..3e0a5a1 100644 --- a/src/services/verificationService.ts +++ b/src/services/verificationService.ts @@ -5,6 +5,7 @@ import { isRecentlyCaptured, hasFutureCaptureSkew, } from './photoService'; +import { getProofTaskApprovalBlockReason } from '../utils/proofApproval'; interface VerificationResult { verdict: 'approved' | 'rejected' | 'inconclusive'; @@ -19,6 +20,16 @@ export async function autoVerify(proofId: string): Promise { }); if (!proof) throw new Error('Proof not found'); + const proofCreatedAt = proof.createdAt ?? new Date(); + const approvalBlockReason = getProofTaskApprovalBlockReason(proofCreatedAt, proof.task); + if (approvalBlockReason) { + return { + verdict: 'rejected', + confidence: 0, + notes: approvalBlockReason, + }; + } + const checks: { pass: boolean; weight: number; name: string }[] = []; // ── GPS location ──────────────────────────────────────────────────────────── @@ -54,7 +65,6 @@ export async function autoVerify(proofId: string): Promise { // ── Timestamp skew (EXIF capturedAt must not be in the future relative to // proof submission — physically impossible; indicates a forged timestamp) ── - const proofCreatedAt = proof.createdAt ?? new Date(); const hasSkewedTimestamp = proof.photos.some((photo) => hasFutureCaptureSkew(photo.capturedAt, proofCreatedAt), ); @@ -85,12 +95,9 @@ export async function autoVerify(proofId: string): Promise { checks.push({ pass: true, weight: 0.1, name: 'photo_not_duplicate' }); // ── Task expiry ───────────────────────────────────────────────────────────── - if (proof.task.expiresAt) { - const expired = new Date() > proof.task.expiresAt; - checks.push({ pass: !expired, weight: 0.2, name: 'task_not_expired' }); - } else { - checks.push({ pass: true, weight: 0.2, name: 'task_no_expiry' }); - } + // Deadline eligibility is a hard gate above. Eligible proofs retain this + // signal even when queue or review latency pushes finalization past expiry. + checks.push({ pass: true, weight: 0.2, name: 'proof_submitted_by_task_deadline' }); const score = checks.reduce((sum, c) => sum + (c.pass ? c.weight : 0), 0); diff --git a/src/utils/proofApproval.ts b/src/utils/proofApproval.ts new file mode 100644 index 0000000..f8be5d5 --- /dev/null +++ b/src/utils/proofApproval.ts @@ -0,0 +1,25 @@ +export const TASK_EXPIRED_BEFORE_SUBMISSION_NOTE = 'task_expired_before_proof_submission'; +export const TASK_UNAVAILABLE_FOR_APPROVAL_NOTE = 'task_unavailable_for_approval'; + +/** + * A task deadline applies when the proof is submitted, not when an + * asynchronous verifier happens to finalize it. The boundary is inclusive: + * a proof created exactly at expiresAt is still eligible. + */ +export function getProofTaskApprovalBlockReason( + proofCreatedAt: Date, + task: { status: string; expiresAt: Date | null }, +): string | null { + if ( + (task.expiresAt !== null && proofCreatedAt.getTime() > task.expiresAt.getTime()) || + (task.status === 'EXPIRED' && task.expiresAt === null) + ) { + return TASK_EXPIRED_BEFORE_SUBMISSION_NOTE; + } + + if (task.status !== 'ACTIVE' && task.status !== 'EXPIRED') { + return TASK_UNAVAILABLE_FOR_APPROVAL_NOTE; + } + + return null; +} diff --git a/src/workers/verificationWorker.ts b/src/workers/verificationWorker.ts index 2177061..6bc9b47 100644 --- a/src/workers/verificationWorker.ts +++ b/src/workers/verificationWorker.ts @@ -9,6 +9,7 @@ import logger from '../utils/logger'; import { redisConnectionManager } from '../utils/redisConnectionManager.js'; import { getRequestId, runWithRequestContext } from '../utils/requestContext.js'; import { getQueueRetentionOptions, QUEUE_NAMES } from './queueRetention.js'; +import { getProofTaskApprovalBlockReason } from '../utils/proofApproval.js'; // BullMQ bundles its own ioredis, so its `ConnectionOptions` is a structurally // distinct type from our top-level ioredis `Redis`. The cast is purely @@ -69,7 +70,12 @@ const worker = new Worker( const proof = await prisma.proof.findUnique({ where: { id: proofId }, - select: { userId: true, taskId: true }, + select: { + userId: true, + taskId: true, + createdAt: true, + task: { select: { status: true, expiresAt: true } }, + }, }); if (!proof) throw new Error('Proof not found'); @@ -81,12 +87,21 @@ const worker = new Worker( let taskCompleted = false; let notes = result.notes || `confidence: ${result.confidence}`; - const slot = await claimCompletionSlot(tx, proof.taskId); - if (!slot.claimed) { + const approvalBlockReason = getProofTaskApprovalBlockReason( + proof.createdAt, + proof.task, + ); + if (approvalBlockReason) { finalStatus = 'REJECTED'; - notes += ' [auto-rejected: task reached max completions]'; + notes += ` [auto-rejected: ${approvalBlockReason}]`; } else { - taskCompleted = slot.taskCompleted; + const slot = await claimCompletionSlot(tx, proof.taskId, proof.createdAt); + if (!slot.claimed) { + finalStatus = 'REJECTED'; + notes += ' [auto-rejected: task unavailable or at capacity]'; + } else { + taskCompleted = slot.taskCompleted; + } } await tx.proof.update({ @@ -127,7 +142,7 @@ const worker = new Worker( }); } } else { - logger.info('Auto-approved proof rejected: task at capacity', { + logger.info('Auto-approved proof rejected by task approval gate', { proofId, taskId: proof.taskId, ...requestMeta, diff --git a/tests/integration/proof-to-reward.test.ts b/tests/integration/proof-to-reward.test.ts index f3d028b..cd1e207 100644 --- a/tests/integration/proof-to-reward.test.ts +++ b/tests/integration/proof-to-reward.test.ts @@ -35,6 +35,7 @@ describe('Proof-to-Reward Integration', () => { userId: 'user-1', taskId: 'task-1', status: 'PENDING', + createdAt: new Date(), lat: -1.2921, lng: 36.8219, photos: [{ id: 'photo-1', cid: 'cid-1' }], @@ -44,6 +45,7 @@ describe('Proof-to-Reward Integration', () => { lat: -1.2921, lng: 36.8219, radiusMeters: 100, + status: 'ACTIVE', rewardAmountMicros: 500000000n, rewardToken: 'ECO', expiresAt: null, @@ -68,6 +70,7 @@ describe('Proof-to-Reward Integration', () => { id: 'proof-2', lat: null, lng: null, + createdAt: new Date(), photos: [], user: { wallet: 'GC...USER...' }, task: { @@ -75,6 +78,7 @@ describe('Proof-to-Reward Integration', () => { lat: -1.2921, lng: 36.8219, radiusMeters: 100, + status: 'EXPIRED', rewardAmountMicros: 500000000n, rewardToken: 'ECO', expiresAt: yesterday, @@ -83,6 +87,6 @@ describe('Proof-to-Reward Integration', () => { const result = await autoVerify('proof-2'); expect(result.verdict).toBe('rejected'); - expect(result.notes).toMatch(/gps/); + expect(result.notes).toBe('task_expired_before_proof_submission'); }); }); diff --git a/tests/models/task.concurrency.test.ts b/tests/models/task.concurrency.test.ts index d5b4dbb..620a7d7 100644 --- a/tests/models/task.concurrency.test.ts +++ b/tests/models/task.concurrency.test.ts @@ -28,7 +28,7 @@ describe('claimCompletionSlot: atomic capacity enforcement (real DB)', () => { it('never exceeds maxCompletions under 6 simultaneous approvals, and completes exactly once', async () => { const attempts = Array.from({ length: 6 }, () => - prisma.$transaction((tx) => claimCompletionSlot(tx, taskId)), + prisma.$transaction((tx) => claimCompletionSlot(tx, taskId, new Date())), ); const results = await Promise.all(attempts); @@ -43,4 +43,4 @@ describe('claimCompletionSlot: atomic capacity enforcement (real DB)', () => { expect(finalTask.completedCount).toBe(5); expect(finalTask.status).toBe('COMPLETED'); }); -}); \ No newline at end of file +}); diff --git a/tests/routes/proofs.test.ts b/tests/routes/proofs.test.ts index 3241372..0217b43 100644 --- a/tests/routes/proofs.test.ts +++ b/tests/routes/proofs.test.ts @@ -146,6 +146,24 @@ describe('Proof Routes', () => { expect(res.body.error).toBe('task is not active'); }); + it('returns 400 after the deadline even when the expiry sweep has not run', async () => { + mockPrisma.task.findUnique.mockResolvedValue({ + id: 'task-1', + status: 'ACTIVE', + expiresAt: new Date(Date.now() - 1000), + }); + + const res = await request(app) + .post('/proofs') + .set('Authorization', `Bearer ${userToken()}`) + .field('taskId', VALID_UUID); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('task is not active'); + expect(mockPrisma.taskClaim.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.proof.create).not.toHaveBeenCalled(); + }); + it('returns 201 and creates proof with photo tied to the active claim', async () => { mockPrisma.task.findUnique.mockResolvedValue({ id: 'task-1', status: 'ACTIVE' }); mockPrisma.taskClaim.findFirst.mockResolvedValue({ id: 'claim-1' }); @@ -669,13 +687,19 @@ describe('Proof Routes', () => { }); }); - it('approves a proof, completes capacity and creates payout outbox row', async () => { + it('approves an on-time proof finalized after the task expiry sweep', async () => { + const createdAt = new Date('2026-08-23T11:59:59.000Z'); mockPrisma.user.findUnique.mockResolvedValue({ role: 'admin' }); mockPrisma.proof.findUnique.mockResolvedValueOnce({ id: 'proof-1', userId: 'user-id', taskId: 'task-1', status: 'VERIFYING', + createdAt, + task: { + status: 'EXPIRED', + expiresAt: new Date('2026-08-23T12:00:00.000Z'), + }, }); mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); mockPrisma.verification.create.mockResolvedValue({}); @@ -698,10 +722,52 @@ describe('Proof Routes', () => { .send({ verdict: 'approved' }); expect(res.status).toBe(200); expect(res.body.status).toBe('APPROVED'); - expect(claimCompletionSlot).toHaveBeenCalledWith(mockPrisma, 'task-1'); + expect(claimCompletionSlot).toHaveBeenCalledWith(mockPrisma, 'task-1', createdAt); expect(mockPrisma.rewardPayout.create).toHaveBeenCalledWith({ data: expect.objectContaining({ proofId: 'proof-1' }), }); }); + + it('forces an admin approval to rejected when proof was submitted after expiry', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ role: 'admin' }); + mockPrisma.proof.findUnique.mockResolvedValueOnce({ + id: 'proof-expired', + userId: 'user-id', + taskId: 'task-1', + status: 'VERIFYING', + createdAt: new Date('2026-08-23T12:00:01.000Z'), + task: { + status: 'EXPIRED', + expiresAt: new Date('2026-08-23T12:00:00.000Z'), + }, + }); + mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.verification.create.mockResolvedValue({}); + mockPrisma.proof.findUnique.mockResolvedValueOnce({ + id: 'proof-expired', + status: 'REJECTED', + photos: [], + verifications: [], + }); + const { claimCompletionSlot } = jest.requireMock('../../src/models/task') as { + claimCompletionSlot: jest.Mock; + }; + + const res = await request(app) + .post('/proofs/proof-expired/review') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ verdict: 'approved' }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('REJECTED'); + expect(claimCompletionSlot).not.toHaveBeenCalled(); + expect(mockPrisma.rewardPayout.create).not.toHaveBeenCalled(); + expect(mockPrisma.verification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + verdict: 'approved', + notes: expect.stringContaining('task_expired_before_proof_submission'), + }), + }); + }); }); }); diff --git a/tests/services/validatorService.test.ts b/tests/services/validatorService.test.ts index 9b5afc5..cfdd9ec 100644 --- a/tests/services/validatorService.test.ts +++ b/tests/services/validatorService.test.ts @@ -199,6 +199,8 @@ describe('ValidatorService', () => { mockPrisma.proof.findUnique.mockResolvedValueOnce({ taskId: 'task-1', status: 'VERIFYING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, }); mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); mockPrisma.proof.findUnique.mockResolvedValueOnce({ @@ -232,7 +234,11 @@ describe('ValidatorService', () => { mockPrisma, 'request-1', ); - expect(claimCompletionSlot).toHaveBeenCalledWith(mockPrisma, 'task-1'); + expect(claimCompletionSlot).toHaveBeenCalledWith( + mockPrisma, + 'task-1', + new Date('2026-08-23T11:00:00.000Z'), + ); expect(mockPrisma.rewardPayout.create).toHaveBeenCalledWith({ data: { proofId: 'proof-1', requestId: 'request-1' }, }); @@ -253,6 +259,8 @@ describe('ValidatorService', () => { mockPrisma.proof.findUnique.mockResolvedValueOnce({ taskId: 'task-1', status: 'VERIFYING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, }); mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); mockPrisma.proof.findUnique.mockResolvedValueOnce({ @@ -296,10 +304,20 @@ describe('ValidatorService', () => { mockPrisma.proof.findUnique .mockResolvedValueOnce(votesProof(['approved', 'approved'])) - .mockResolvedValueOnce({ taskId: 'task-1', status: 'VERIFYING' }) + .mockResolvedValueOnce({ + taskId: 'task-1', + status: 'VERIFYING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, + }) .mockResolvedValueOnce({ userId: 'owner-1', taskId: 'task-1' }) .mockResolvedValueOnce(votesProof(['approved', 'approved'])) - .mockResolvedValueOnce({ taskId: 'task-1', status: 'VERIFYING' }); + .mockResolvedValueOnce({ + taskId: 'task-1', + status: 'VERIFYING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, + }); let updateManyCount = 0; mockPrisma.proof.updateMany.mockImplementation(async () => { @@ -382,5 +400,44 @@ describe('ValidatorService', () => { data: { status: 'REJECTED' }, }); }); + + it('forces an approving quorum to reject a proof submitted after expiry', async () => { + mockPrisma.proof.findUnique + .mockResolvedValueOnce(votesProof(['approved', 'approved', null])) + .mockResolvedValueOnce({ + taskId: 'task-1', + status: 'VERIFYING', + createdAt: new Date('2026-08-23T12:00:01.000Z'), + task: { + status: 'EXPIRED', + expiresAt: new Date('2026-08-23T12:00:00.000Z'), + }, + }); + mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.proof.findUnique.mockResolvedValueOnce({ + userId: 'owner-1', + taskId: 'task-1', + }); + mockPrisma.verification.create.mockResolvedValue({}); + const { claimCompletionSlot } = jest.requireMock('../../src/models/task') as { + claimCompletionSlot: jest.Mock; + }; + + const outcome = await resolveQuorum('proof-1'); + + expect(outcome).toEqual({ finalized: true, status: 'REJECTED' }); + expect(claimCompletionSlot).not.toHaveBeenCalled(); + expect(mockPrisma.proof.updateMany).toHaveBeenCalledWith({ + where: { id: 'proof-1', status: { in: ['PENDING', 'VERIFYING'] } }, + data: { status: 'REJECTED' }, + }); + expect(mockPrisma.rewardPayout.create).not.toHaveBeenCalled(); + expect(mockPrisma.verification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + verdict: 'approved', + notes: expect.stringContaining('task_expired_before_proof_submission'), + }), + }); + }); }); }); diff --git a/tests/services/verificationService.test.ts b/tests/services/verificationService.test.ts index b950992..e0466d5 100644 --- a/tests/services/verificationService.test.ts +++ b/tests/services/verificationService.test.ts @@ -26,6 +26,7 @@ function makeTask(overrides: Record = {}) { lat: -1.2921, lng: 36.8219, radiusMeters: 100, + status: 'ACTIVE', expiresAt: null, rewardAmountMicros: 500000000n, rewardToken: 'ECO', @@ -88,7 +89,7 @@ describe('VerificationService', () => { expect(result.confidence).toBeLessThan(0.4); }); - it('returns inconclusive for partial data (GPS but no photos, expired)', async () => { + it('hard-rejects partial proof data submitted after task expiry', async () => { const yesterday = new Date(Date.now() - 86400000); mockPrisma.proof.findUnique.mockResolvedValue({ id: 'proof-3', @@ -100,9 +101,11 @@ describe('VerificationService', () => { }); const result = await autoVerify('proof-3'); - expect(result.verdict).toBe('inconclusive'); - expect(result.confidence).toBeGreaterThanOrEqual(0.4); - expect(result.confidence).toBeLessThan(0.7); + expect(result).toEqual({ + verdict: 'rejected', + confidence: 0, + notes: 'task_expired_before_proof_submission', + }); }); it('returns inconclusive for proof outside GPS radius', async () => { @@ -119,7 +122,7 @@ describe('VerificationService', () => { expect(result.verdict).toBe('inconclusive'); }); - it('returns inconclusive for expired task with valid GPS but no photos', async () => { + it('hard-rejects an otherwise plausible proof submitted after expiry', async () => { const yesterday = new Date(Date.now() - 86400000); mockPrisma.proof.findUnique.mockResolvedValue({ id: 'proof-5', @@ -131,7 +134,40 @@ describe('VerificationService', () => { }); const result = await autoVerify('proof-5'); - expect(result.verdict).toBe('inconclusive'); + expect(result.verdict).toBe('rejected'); + expect(result.confidence).toBe(0); + }); + + it('allows a proof submitted before the deadline to finalize after the task expires', async () => { + const expiresAt = new Date('2026-08-23T12:00:00.000Z'); + mockPrisma.proof.findUnique.mockResolvedValue({ + id: 'proof-grandfathered', + lat: -1.2921, + lng: 36.8219, + createdAt: new Date('2026-08-23T11:59:59.000Z'), + photos: [{ id: 'photo-1', cid: 'cid-1', filename: 'test.jpg' }], + task: makeTask({ status: 'EXPIRED', expiresAt }), + }); + + const result = await autoVerify('proof-grandfathered'); + + expect(result.verdict).toBe('approved'); + }); + + it('treats submission exactly at expiresAt as eligible', async () => { + const expiresAt = new Date('2026-08-23T12:00:00.000Z'); + mockPrisma.proof.findUnique.mockResolvedValue({ + id: 'proof-at-deadline', + lat: -1.2921, + lng: 36.8219, + createdAt: expiresAt, + photos: [{ id: 'photo-1', cid: 'cid-1', filename: 'test.jpg' }], + task: makeTask({ status: 'EXPIRED', expiresAt }), + }); + + const result = await autoVerify('proof-at-deadline'); + + expect(result.verdict).toBe('approved'); }); it('rejects a proof reusing a photo already submitted elsewhere', async () => { @@ -326,17 +362,17 @@ describe('VerificationService', () => { it.each([ // hasPhotos, inRadius, expired, expectedVerdict, expectedConfidence [true, true, false, 'approved', 1.05], - [true, true, true, 'approved', 0.85], + [true, true, true, 'rejected', 0], [true, false, false, 'inconclusive', 0.65], - [true, false, true, 'inconclusive', 0.45], + [true, false, true, 'rejected', 0], // Photo-less proofs: never approved, regardless of how favorable the // remaining GPS/expiry signals are — this is the regression guard for // the auto-approval bug (in-radius + no-expiry used to score 0.75 and // clear the 0.7 approval threshold with zero photographic evidence). [false, true, false, 'inconclusive', 0.75], - [false, true, true, 'inconclusive', 0.55], + [false, true, true, 'rejected', 0], [false, false, false, 'rejected', 0.35], - [false, false, true, 'rejected', 0.15], + [false, false, true, 'rejected', 0], ] as const)( 'hasPhotos=%s inRadius=%s expired=%s → %s (confidence %s)', async (hasPhotos, inRadius, expired, expectedVerdict, expectedConfidence) => { diff --git a/tests/workers/verificationWorker.test.ts b/tests/workers/verificationWorker.test.ts index 4c46fbe..446f852 100644 --- a/tests/workers/verificationWorker.test.ts +++ b/tests/workers/verificationWorker.test.ts @@ -136,6 +136,8 @@ describe('Verification Worker', () => { userId: 'user-1', taskId: 'task-1', status: 'PENDING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, }); mockPrisma.proof.update.mockResolvedValue({}); const { autoVerify } = jest.requireMock('../../src/services/verificationService') as { @@ -152,11 +154,14 @@ describe('Verification Worker', () => { }); it('approves valid proofs, checks capacity and creates payout outbox row', async () => { + const createdAt = new Date('2026-08-23T11:00:00.000Z'); mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); mockPrisma.proof.findUnique.mockResolvedValue({ userId: 'user-1', taskId: 'task-1', status: 'PENDING', + createdAt, + task: { status: 'ACTIVE', expiresAt: null }, }); mockPrisma.proof.update.mockResolvedValue({}); const { autoVerify } = jest.requireMock('../../src/services/verificationService') as { @@ -186,7 +191,7 @@ describe('Verification Worker', () => { where: { id: 'proof-1' }, data: { status: 'APPROVED' }, }); - expect(claimCompletionSlot).toHaveBeenCalledWith(mockPrisma, 'task-1'); + expect(claimCompletionSlot).toHaveBeenCalledWith(mockPrisma, 'task-1', createdAt); expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); expect(notifyProofStatus).toHaveBeenCalledWith( @@ -234,12 +239,50 @@ describe('Verification Worker', () => { expect(mockPrisma.rewardPayout.create).not.toHaveBeenCalled(); }); + it('rejects a post-deadline proof even if autoVerify reports approved', async () => { + const createdAt = new Date('2026-08-23T12:00:01.000Z'); + mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.proof.findUnique.mockResolvedValue({ + userId: 'user-1', + taskId: 'task-1', + createdAt, + task: { + status: 'EXPIRED', + expiresAt: new Date('2026-08-23T12:00:00.000Z'), + }, + }); + mockPrisma.proof.update.mockResolvedValue({}); + const { autoVerify } = jest.requireMock('../../src/services/verificationService') as { + autoVerify: jest.Mock; + }; + autoVerify.mockResolvedValue({ verdict: 'approved', confidence: 0.9 }); + const { claimCompletionSlot } = jest.requireMock('../../src/models/task') as { + claimCompletionSlot: jest.Mock; + }; + + await processor({ id: 'job-expired', data: { proofId: 'proof-1' } }); + + expect(claimCompletionSlot).not.toHaveBeenCalled(); + expect(mockPrisma.proof.update).toHaveBeenCalledWith({ + where: { id: 'proof-1' }, + data: { status: 'REJECTED' }, + }); + expect(mockPrisma.rewardPayout.create).not.toHaveBeenCalled(); + expect(mockPrisma.verification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + notes: expect.stringContaining('task_expired_before_proof_submission'), + }), + }); + }); + it('assigns inconclusive proofs to community validators', async () => { mockPrisma.proof.updateMany.mockResolvedValue({ count: 1 }); mockPrisma.proof.findUnique.mockResolvedValue({ userId: 'user-1', taskId: 'task-1', status: 'PENDING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, }); mockPrisma.proof.update.mockResolvedValue({}); const { autoVerify } = jest.requireMock('../../src/services/verificationService') as { @@ -273,6 +316,8 @@ describe('Verification Worker', () => { userId: 'user-1', taskId: 'task-1', status: 'PENDING', + createdAt: new Date('2026-08-23T11:00:00.000Z'), + task: { status: 'ACTIVE', expiresAt: null }, }); mockPrisma.proof.update.mockResolvedValue({}); const { autoVerify } = jest.requireMock('../../src/services/verificationService') as {