import { beforeEach, describe, expect, it } from "vitest"; import { db } from "@/db"; import { tags } from "@/db/schema"; import { verifyPassword } from "@/lib/auth/password"; import { buildSiteExport, importSiteExport } from "@/lib/services/import-export"; import { createPost, getPostById, listAllPosts, type PostInput } from "@/lib/services/posts"; import { createUser, deleteUser, getAllowedTagIds, getUserWithTags, listUsersWithTags, updateUser, } from "@/lib/services/users"; import { resetDb } from "../helpers/db"; const postInput = (overrides: Partial = {}): PostInput => ({ title: `Post ${Math.random().toString(36).slice(2, 8)}`, slug: "", body: "body", authorName: "Someone", featuredImageUrl: null, featuredImageAlt: null, status: "published", tagIds: [], newTagNames: [], ...overrides, }); async function seedTags(...names: string[]) { return db .insert(tags) .values(names.map((name) => ({ name, slug: name.toLowerCase() }))) .returning(); } async function seedAdmin() { const [admin] = await db .insert((await import("@/db/schema")).users) .values({ username: "boss", passwordHash: "x", role: "admin" }) .returning(); return admin; } beforeEach(resetDb); describe("account management", () => { it("creates author accounts with tag grants", async () => { const [design, writing] = await seedTags("Design", "Writing"); const user = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [design.id, writing.id], }); expect(user.role).toBe("author"); expect((await getAllowedTagIds(user.id)).sort()).toEqual( [design.id, writing.id].sort(), ); const listed = await listUsersWithTags(); expect(listed.find((u) => u.username === "casey")?.tags.map((t) => t.name).sort()).toEqual( ["Design", "Writing"], ); }); it("hashes passwords and updates them only when provided", async () => { const user = await createUser({ username: "casey", password: "first-password", tagIds: [] }); const { users } = await import("@/db/schema"); const { eq } = await import("drizzle-orm"); const [row] = await db.select().from(users).where(eq(users.id, user.id)); expect(row.passwordHash).not.toContain("first-password"); expect(await verifyPassword(row.passwordHash, "first-password")).toBe(true); await updateUser(user.id, { password: null, tagIds: [] }); const [same] = await db.select().from(users).where(eq(users.id, user.id)); expect(same.passwordHash).toBe(row.passwordHash); await updateUser(user.id, { password: "second-password", tagIds: [] }); const [changed] = await db.select().from(users).where(eq(users.id, user.id)); expect(await verifyPassword(changed.passwordHash, "second-password")).toBe(true); }); it("replaces tag grants on update", async () => { const [design, writing] = await seedTags("Design", "Writing"); const user = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [design.id], }); await updateUser(user.id, { password: null, tagIds: [writing.id] }); expect(await getAllowedTagIds(user.id)).toEqual([writing.id]); }); it("refuses to delete the admin and keeps a deleted author's posts", async () => { const admin = await seedAdmin(); expect((await deleteUser(admin.id)).ok).toBe(false); const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); const post = await createPost(postInput(), author.id); expect((await deleteUser(author.id)).ok).toBe(true); // Post survives, now unowned. const kept = await getPostById(post.id); expect(kept).not.toBeNull(); expect(kept?.authorId).toBeNull(); expect(await getUserWithTags(author.id)).toBeNull(); }); }); describe("post ownership", () => { it("stamps the creating account and filters listAllPosts by author", async () => { const admin = await seedAdmin(); const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); await createPost(postInput({ title: "Admin post" }), admin.id); await createPost(postInput({ title: "Casey post" }), author.id); const all = await listAllPosts(); expect(all).toHaveLength(2); const mine = await listAllPosts({ authorId: author.id }); expect(mine.map((p) => p.title)).toEqual(["Casey post"]); }); it("round-trips ownership through export/import by username", async () => { await seedAdmin(); const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); const post = await createPost(postInput({ title: "Owned" }), author.id); const snapshot = await buildSiteExport(); expect(snapshot.posts.find((p) => p.slug === post.slug)?.authorUsername).toBe("casey"); await importSiteExport(snapshot); const restored = (await listAllPosts({ authorId: author.id })).find( (p) => p.title === "Owned", ); expect(restored).toBeDefined(); expect(restored?.authorId).toBe(author.id); }); it("leaves posts unowned when the export references an unknown username", async () => { await seedAdmin(); const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); await createPost(postInput({ title: "Orphan-to-be" }), author.id); const snapshot = await buildSiteExport(); await deleteUser(author.id); await importSiteExport(snapshot); const [post] = await listAllPosts(); expect(post.authorId).toBeNull(); }); });