import { describe, expect, it } from 'vitest'; import { openDatabase } from '../db/database.js'; import { backfillMissingClaimedAtBlock, findExpiredClaims, isClaimExpired, sweepExpiredClaims } from './expiry.js'; import type { Claim } from '../types.js'; function seedPlace(db: ReturnType, id: number) { db.prepare('INSERT INTO places (id, lat, lon, name, synced_at) VALUES (?, ?, ?, ?, ?)').run( id, 10, 10 + id, `place-${id}`, 0, ); } function seedUser(db: ReturnType, pubkey: string, team: 'orange' | 'green') { db.prepare('INSERT INTO users (pubkey, team, created_at, last_login_at) VALUES (?, ?, 0, 0)').run(pubkey, team); } function seedClaim( db: ReturnType, placeId: number, team: 'orange' | 'green', pubkey: string, claimedAtBlock: number | null, ) { db.prepare( 'INSERT INTO claims (place_id, team, claimed_by_pubkey, claimed_at, claimed_at_block) VALUES (?, ?, ?, 0, ?)', ).run(placeId, team, pubkey, claimedAtBlock); } describe('isClaimExpired', () => { it('is false for undefined claim', () => { expect(isClaimExpired(undefined, 900_100, 2100)).toBe(false); }); it('is false when claimed_at_block is null (pre-migration row not yet backfilled)', () => { const claim = { claimed_at_block: null } as Claim; expect(isClaimExpired(claim, 900_100, 2100)).toBe(false); }); it('is false just under the timeout', () => { const claim = { claimed_at_block: 900_000 } as Claim; expect(isClaimExpired(claim, 900_000 + 2099, 2100)).toBe(false); }); it('is true exactly at the timeout', () => { const claim = { claimed_at_block: 900_000 } as Claim; expect(isClaimExpired(claim, 900_000 + 2100, 2100)).toBe(true); }); it('is true well past the timeout', () => { const claim = { claimed_at_block: 900_000 } as Claim; expect(isClaimExpired(claim, 900_000 + 5000, 2100)).toBe(true); }); }); describe('findExpiredClaims / sweepExpiredClaims', () => { it('finds and expires only claims past the timeout, tearing down their links', () => { const db = openDatabase(':memory:'); seedPlace(db, 1); seedPlace(db, 2); seedPlace(db, 3); seedUser(db, 'pk1', 'orange'); seedClaim(db, 1, 'orange', 'pk1', 900_000); // will be expired seedClaim(db, 2, 'orange', 'pk1', 899_000); // long expired seedClaim(db, 3, 'orange', 'pk1', 902_000); // still fresh db.prepare( 'INSERT INTO links (from_place_id, to_place_id, team, created_by_pubkey, created_at) VALUES (1, 2, ?, ?, 0)', ).run('orange', 'pk1'); const currentHeight = 902_100; // 2100 blocks past place 1 and 2, not place 3 const found = findExpiredClaims(db, currentHeight, 2100); expect(found.map((c) => c.placeId).sort()).toEqual([1, 2]); const swept = sweepExpiredClaims(db, currentHeight, 2100); expect(swept.map((c) => c.placeId).sort()).toEqual([1, 2]); const remainingClaims = db.prepare('SELECT place_id FROM claims').all() as { place_id: number }[]; expect(remainingClaims.map((c) => c.place_id)).toEqual([3]); const remainingLinks = db.prepare('SELECT * FROM links').all(); expect(remainingLinks).toHaveLength(0); // the 1-2 link died with either endpoint expiring db.close(); }); it('is a no-op when nothing has expired', () => { const db = openDatabase(':memory:'); seedPlace(db, 1); seedUser(db, 'pk1', 'orange'); seedClaim(db, 1, 'orange', 'pk1', 900_000); expect(sweepExpiredClaims(db, 900_500, 2100)).toHaveLength(0); expect(db.prepare('SELECT COUNT(*) c FROM claims').get()).toEqual({ c: 1 }); db.close(); }); it('ignores claims with no claimed_at_block yet (not backfilled)', () => { const db = openDatabase(':memory:'); seedPlace(db, 1); seedUser(db, 'pk1', 'orange'); seedClaim(db, 1, 'orange', 'pk1', null); expect(sweepExpiredClaims(db, 999_999_999, 2100)).toHaveLength(0); expect(db.prepare('SELECT COUNT(*) c FROM claims').get()).toEqual({ c: 1 }); db.close(); }); }); describe('backfillMissingClaimedAtBlock', () => { it('sets claimed_at_block only for rows where it is currently null', () => { const db = openDatabase(':memory:'); seedPlace(db, 1); seedPlace(db, 2); seedUser(db, 'pk1', 'orange'); seedClaim(db, 1, 'orange', 'pk1', null); seedClaim(db, 2, 'orange', 'pk1', 500_000); // already has one — must not be overwritten const changed = backfillMissingClaimedAtBlock(db, 900_000); expect(changed).toBe(1); const rows = db.prepare('SELECT place_id, claimed_at_block FROM claims ORDER BY place_id').all(); expect(rows).toEqual([ { place_id: 1, claimed_at_block: 900_000 }, { place_id: 2, claimed_at_block: 500_000 }, ]); db.close(); }); });