import { beforeEach, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; import * as schema from "../../src/db/schema.ts"; import { createSetup, updateSetup, } from "../../src/server/services/setup.service.ts"; import { createShareLink, deactivateShareLinks, getShareLinks, reactivateShareLinks, revokeShareLink, validateShareToken, } from "../../src/server/services/share.service.ts"; import { createSecondTestUser, createTestDb } from "../helpers/db.ts"; type TestDb = Awaited>; describe("Share Service", () => { let db: TestDb["db"]; let userId: number; let setupId: number; beforeEach(async () => { const testData = await createTestDb(); db = testData.db; userId = testData.userId; const setup = await createSetup(db, userId, { name: "Test Setup", visibility: "link", }); setupId = setup.id; }); describe("createShareLink", () => { it("creates a share link with token and expiration", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: 7, }); expect(share).not.toBeNull(); expect(share!.token).toBeTruthy(); expect(share!.token.length).toBeGreaterThanOrEqual(20); expect(share!.setupId).toBe(setupId); expect(share!.permission).toBe("read"); expect(share!.expiresAt).not.toBeNull(); expect(share!.revokedAt).toBeNull(); }); it("creates a share link with no expiration when null", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: null, }); expect(share).not.toBeNull(); expect(share!.expiresAt).toBeNull(); }); it("returns null for non-owned setup", async () => { const otherUserId = await createSecondTestUser(db); const share = await createShareLink(db, otherUserId, setupId, { expiresInDays: 14, }); expect(share).toBeNull(); }); it("generates unique URL-safe tokens", async () => { const share1 = await createShareLink(db, userId, setupId, { expiresInDays: 14, }); const share2 = await createShareLink(db, userId, setupId, { expiresInDays: 14, }); expect(share1!.token).not.toBe(share2!.token); // base64url should not contain +, /, or = expect(share1!.token).not.toMatch(/[+/=]/); }); }); describe("getShareLinks", () => { it("returns all shares for a setup owned by the user", async () => { await createShareLink(db, userId, setupId, { expiresInDays: 7 }); await createShareLink(db, userId, setupId, { expiresInDays: 14 }); const links = await getShareLinks(db, userId, setupId); expect(links).not.toBeNull(); expect(links!).toHaveLength(2); }); it("returns null for non-owned setup", async () => { const otherUserId = await createSecondTestUser(db); const links = await getShareLinks(db, otherUserId, setupId); expect(links).toBeNull(); }); }); describe("revokeShareLink", () => { it("sets revokedAt on the share", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: 14, }); const revoked = await revokeShareLink(db, userId, share!.id); expect(revoked).not.toBeNull(); expect(revoked!.revokedAt).not.toBeNull(); }); it("returns null for non-owned share", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: 14, }); const otherUserId = await createSecondTestUser(db); const result = await revokeShareLink(db, otherUserId, share!.id); expect(result).toBeNull(); }); }); describe("validateShareToken", () => { it("returns setupId for valid token", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: 14, }); const result = await validateShareToken(db, share!.token); expect(result).not.toBeNull(); expect(result!.setupId).toBe(setupId); expect(result!.permission).toBe("read"); }); it("returns null for expired token", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: 1, }); // Manually set expiresAt to the past await db .update(schema.shares) .set({ expiresAt: new Date(Date.now() - 86400000) }) .where(eq(schema.shares.id, share!.id)); const result = await validateShareToken(db, share!.token); expect(result).toBeNull(); }); it("returns null for revoked token", async () => { const share = await createShareLink(db, userId, setupId, { expiresInDays: 14, }); await revokeShareLink(db, userId, share!.id); const result = await validateShareToken(db, share!.token); expect(result).toBeNull(); }); it("returns null for nonexistent token", async () => { const result = await validateShareToken(db, "nonexistent-token"); expect(result).toBeNull(); }); }); describe("deactivateShareLinks", () => { it("sets revokedAt on all active links for a setup", async () => { await createShareLink(db, userId, setupId, { expiresInDays: 7 }); await createShareLink(db, userId, setupId, { expiresInDays: null }); await deactivateShareLinks(db, setupId); const links = await getShareLinks(db, userId, setupId); for (const link of links!) { expect(link.revokedAt).not.toBeNull(); } }); }); describe("reactivateShareLinks", () => { it("clears revokedAt on deactivated links", async () => { await createShareLink(db, userId, setupId, { expiresInDays: null }); await createShareLink(db, userId, setupId, { expiresInDays: 14 }); await deactivateShareLinks(db, setupId); await reactivateShareLinks(db, setupId); const links = await getShareLinks(db, userId, setupId); for (const link of links!) { expect(link.revokedAt).toBeNull(); } }); }); describe("visibility transition side effects", () => { it("deactivates links when visibility changes to private", async () => { await createShareLink(db, userId, setupId, { expiresInDays: 14 }); await updateSetup(db, userId, setupId, { name: "Test Setup", visibility: "private", }); const links = await getShareLinks(db, userId, setupId); for (const link of links!) { expect(link.revokedAt).not.toBeNull(); } }); it("reactivates links when visibility changes from private to link", async () => { await createShareLink(db, userId, setupId, { expiresInDays: null }); // Deactivate by going private await updateSetup(db, userId, setupId, { name: "Test Setup", visibility: "private", }); // Reactivate by going back to link await updateSetup(db, userId, setupId, { name: "Test Setup", visibility: "link", }); const links = await getShareLinks(db, userId, setupId); for (const link of links!) { expect(link.revokedAt).toBeNull(); } }); }); });