/** * Tests for the cleanup subsystems. * * Note: runSystemCleanup() is not tested directly here because it imports * from @emdash-cms/auth/adapters/kysely, which requires the auth package to * be built. Instead, we test each subsystem independently: * - cleanupExpiredChallenges: tested in auth/challenge-store.test.ts * - deleteExpiredTokens: tested below using direct DB operations * - cleanupPendingUploads: tested below via MediaRepository * - pruneOldRevisions: tested below via RevisionRepository */ import type { Kysely } from "kysely"; import { ulid } from "ulidx"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { MediaRepository } from "../../src/database/repositories/media.js"; import { RevisionRepository } from "../../src/database/repositories/revision.js"; import type { Database } from "../../src/database/types.js"; import { setupTestDatabase, setupTestDatabaseWithCollections } from "../utils/test-db.js"; describe("Revision Pruning", () => { let db: Kysely; let revisionRepo: RevisionRepository; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); revisionRepo = new RevisionRepository(db); }); afterEach(async () => { await db.destroy(); }); it("prunes old revisions keeping the most recent N", async () => { const entryId = ulid(); // Create a content entry const { sql } = await import("kysely"); await sql` INSERT INTO ec_post (id, slug, status, created_at, updated_at, version) VALUES (${entryId}, ${"test-post"}, ${"draft"}, ${new Date().toISOString()}, ${new Date().toISOString()}, ${1}) `.execute(db); // Create 200 revisions for (let i = 0; i < 200; i++) { await revisionRepo.create({ collection: "post", entryId, data: { title: `Version ${i + 1}` }, }); } const countBefore = await revisionRepo.countByEntry("post", entryId); expect(countBefore).toBe(200); // Prune to keep 50 const pruned = await revisionRepo.pruneOldRevisions("post", entryId, 50); expect(pruned).toBe(150); const countAfter = await revisionRepo.countByEntry("post", entryId); expect(countAfter).toBe(50); // Verify the remaining 50 are the newest const remaining = await revisionRepo.findByEntry("post", entryId); expect(remaining[0]?.data.title).toBe("Version 200"); expect(remaining[49]?.data.title).toBe("Version 151"); }); it("is a no-op when revision count is at or below keepCount", async () => { const entryId = ulid(); const { sql } = await import("kysely"); await sql` INSERT INTO ec_post (id, slug, status, created_at, updated_at, version) VALUES (${entryId}, ${"test-post-2"}, ${"draft"}, ${new Date().toISOString()}, ${new Date().toISOString()}, ${1}) `.execute(db); // Create 10 revisions for (let i = 0; i < 10; i++) { await revisionRepo.create({ collection: "post", entryId, data: { title: `Version ${i + 1}` }, }); } const pruned = await revisionRepo.pruneOldRevisions("post", entryId, 50); expect(pruned).toBe(0); const countAfter = await revisionRepo.countByEntry("post", entryId); expect(countAfter).toBe(10); }); }); describe("MediaRepository.cleanupPendingUploads", () => { let db: Kysely; let mediaRepo: MediaRepository; beforeEach(async () => { db = await setupTestDatabase(); mediaRepo = new MediaRepository(db); }); afterEach(async () => { await db.destroy(); }); it("deletes pending uploads older than the default 1 hour", async () => { vi.useFakeTimers(); // Create pending uploads for (let i = 0; i < 10; i++) { await mediaRepo.createPending({ filename: `pending-${i}.jpg`, mimeType: "image/jpeg", storageKey: `uploads/pending-${i}.jpg`, }); } // Advance past 1 hour vi.advanceTimersByTime(61 * 60 * 1000); const deletedKeys = await mediaRepo.cleanupPendingUploads(); expect(deletedKeys).toHaveLength(10); // Verify actual storage keys are returned for (let i = 0; i < 10; i++) { expect(deletedKeys).toContain(`uploads/pending-${i}.jpg`); } vi.useRealTimers(); }); it("does not delete recent pending uploads", async () => { // Create pending uploads (current time -- not yet expired) for (let i = 0; i < 5; i++) { await mediaRepo.createPending({ filename: `recent-${i}.jpg`, mimeType: "image/jpeg", storageKey: `uploads/recent-${i}.jpg`, }); } const deletedKeys = await mediaRepo.cleanupPendingUploads(); expect(deletedKeys).toHaveLength(0); }); it("does not delete ready or failed items", async () => { vi.useFakeTimers(); // Create items with different statuses await mediaRepo.create({ filename: "ready.jpg", mimeType: "image/jpeg", storageKey: "uploads/ready.jpg", status: "ready", }); const pending = await mediaRepo.createPending({ filename: "pending.jpg", mimeType: "image/jpeg", storageKey: "uploads/pending.jpg", }); await mediaRepo.markFailed(pending.id); // Advance past 1 hour vi.advanceTimersByTime(61 * 60 * 1000); const deletedKeys = await mediaRepo.cleanupPendingUploads(); expect(deletedKeys).toHaveLength(0); // failed + ready should not be deleted vi.useRealTimers(); const remaining = await db.selectFrom("media").select("id").execute(); expect(remaining).toHaveLength(2); }); it("respects custom maxAgeMs parameter", async () => { vi.useFakeTimers(); await mediaRepo.createPending({ filename: "short-lived.jpg", mimeType: "image/jpeg", storageKey: "uploads/short-lived.jpg", }); // Advance 10 minutes vi.advanceTimersByTime(10 * 60 * 1000); // Cleanup with 5 min max age const deletedKeys = await mediaRepo.cleanupPendingUploads(5 * 60 * 1000); expect(deletedKeys).toHaveLength(1); expect(deletedKeys[0]).toBe("uploads/short-lived.jpg"); vi.useRealTimers(); }); }); describe("Expired token cleanup", () => { let db: Kysely; beforeEach(async () => { db = await setupTestDatabase(); }); afterEach(async () => { await db.destroy(); }); it("deletes expired tokens while keeping valid ones", async () => { const now = new Date(); const expired = new Date(now.getTime() - 60 * 1000).toISOString(); // 1 min ago // Create a test user first (tokens reference users) const userId = ulid(); await db .insertInto("users") .values({ id: userId, email: "test@example.com", name: "Test", avatar_url: null, role: 50, email_verified: 1, disabled: 0, data: null, created_at: now.toISOString(), updated_at: now.toISOString(), }) .execute(); // Create 100 expired tokens for (let i = 0; i < 100; i++) { await db .insertInto("auth_tokens") .values({ hash: `expired-hash-${i}`, user_id: userId, email: "test@example.com", type: "magic_link", role: null, invited_by: null, expires_at: expired, created_at: now.toISOString(), }) .execute(); } // Create 5 valid tokens const validExpiry = new Date(now.getTime() + 15 * 60 * 1000).toISOString(); for (let i = 0; i < 5; i++) { await db .insertInto("auth_tokens") .values({ hash: `valid-hash-${i}`, user_id: userId, email: "test@example.com", type: "magic_link", role: null, invited_by: null, expires_at: validExpiry, created_at: now.toISOString(), }) .execute(); } // Use the DB directly to simulate what deleteExpiredTokens does await db.deleteFrom("auth_tokens").where("expires_at", "<", new Date().toISOString()).execute(); // Verify only valid ones remain const remaining = await db.selectFrom("auth_tokens").select("hash").execute(); expect(remaining).toHaveLength(5); expect(remaining.every((r) => r.hash.startsWith("valid-"))).toBe(true); }); });