import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { eq } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { getDb } from "./db.ts"; import { users, follows } from "@trails-cool/db/schema/journal"; import { localActorIri } from "./actor-iri.ts"; import { recordRemoteFollow, removeRemoteFollow, settleOutgoingFollow, rejectOutgoingFollow, } from "./federation-inbox.server.ts"; // Opt-in: these talk to real Postgres. Gated by an env flag so laptop // runs without Postgres aren't blocked. Same convention as // follow.integration.test.ts. const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; const REMOTE_ACTOR = "https://other-trails.example/users/alice"; const createdUserIds: string[] = []; async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { const db = getDb(); const id = randomUUID(); await db.insert(users).values({ id, email: `${opts.username}@example.test`, username: opts.username, domain: "test.local", profileVisibility: opts.profileVisibility ?? "public", }); createdUserIds.push(id); return id; } describe.runIf(runIntegration)("federation inbox (integration)", () => { beforeAll(() => { process.env.ORIGIN ??= "http://localhost:3000"; }); afterEach(async () => { const db = getDb(); for (const id of createdUserIds.splice(0)) { await db.delete(follows).where(eq(follows.followedUserId, id)); await db.delete(follows).where(eq(follows.followerId, id)); await db.delete(users).where(eq(users.id, id)); } }); it("inbound Follow on a public user records an accepted remote follow", async () => { const username = `fed-pub-${Date.now()}`; const userId = await makeUser({ username }); const { outcome } = await recordRemoteFollow(REMOTE_ACTOR, username); expect(outcome).toBe("accepted"); const db = getDb(); const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); expect(rows).toHaveLength(1); expect(rows[0]!.followerActorIri).toBe(REMOTE_ACTOR); expect(rows[0]!.followerId).toBeNull(); expect(rows[0]!.followedActorIri).toBe(localActorIri(username)); expect(rows[0]!.acceptedAt).not.toBeNull(); }); it("remote followers appear in the followers list (count/list consistency)", async () => { const username = `fed-list-${Date.now()}`; const userId = await makeUser({ username }); await recordRemoteFollow(REMOTE_ACTOR, username); const { listFollowers, countFollowers } = await import("./follow.server.ts"); const [entries, total] = await Promise.all([listFollowers(userId), countFollowers(userId)]); expect(total).toBe(1); expect(entries).toHaveLength(1); const entry = entries[0]!; expect(entry.remote).toBe(true); expect(entry.profileUrl).toBe(REMOTE_ACTOR); // No remote_actors cache row in this test → identity parsed from the IRI. expect(entry.username).toBe("alice"); expect(entry.domain).toBe("other-trails.example"); }); it("inbound Follow is idempotent — replays don't double-insert", async () => { const username = `fed-replay-${Date.now()}`; const userId = await makeUser({ username }); await recordRemoteFollow(REMOTE_ACTOR, username); await recordRemoteFollow(REMOTE_ACTOR, username); const db = getDb(); const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); expect(rows).toHaveLength(1); }); it("inbound Follow on a private user is refused with no row", async () => { const username = `fed-priv-${Date.now()}`; const userId = await makeUser({ username, profileVisibility: "private" }); const { outcome } = await recordRemoteFollow(REMOTE_ACTOR, username); expect(outcome).toBe("refused"); const db = getDb(); const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); expect(rows).toHaveLength(0); }); it("Undo(Follow) removes the remote follow row", async () => { const username = `fed-undo-${Date.now()}`; const userId = await makeUser({ username }); await recordRemoteFollow(REMOTE_ACTOR, username); await removeRemoteFollow(REMOTE_ACTOR, username); const db = getDb(); const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); expect(rows).toHaveLength(0); }); it("Accept(Follow) settles a Pending outgoing follow exactly once", async () => { const username = `fed-accept-${Date.now()}`; const userId = await makeUser({ username }); const db = getDb(); await db.insert(follows).values({ id: randomUUID(), followerId: userId, followedActorIri: REMOTE_ACTOR, acceptedAt: null, }); const first = await settleOutgoingFollow(userId, REMOTE_ACTOR); expect(first.settled).toBe(true); // A replayed Accept is a no-op (row no longer Pending). const second = await settleOutgoingFollow(userId, REMOTE_ACTOR); expect(second.settled).toBe(false); const rows = await db.select().from(follows).where(eq(follows.followerId, userId)); expect(rows[0]!.acceptedAt).not.toBeNull(); }); it("Reject(Follow) deletes the Pending outgoing follow", async () => { const username = `fed-reject-${Date.now()}`; const userId = await makeUser({ username }); const db = getDb(); await db.insert(follows).values({ id: randomUUID(), followerId: userId, followedActorIri: REMOTE_ACTOR, acceptedAt: null, }); await rejectOutgoingFollow(userId, REMOTE_ACTOR); const rows = await db.select().from(follows).where(eq(follows.followerId, userId)); expect(rows).toHaveLength(0); }); it("database enforces exactly-one-follower invariant", async () => { const username = `fed-check-${Date.now()}`; const userId = await makeUser({ username }); const db = getDb(); await expect( db.insert(follows).values({ id: randomUUID(), // Neither followerId nor followerActorIri — must violate the // follows_has_follower_check constraint. followedActorIri: localActorIri(username), followedUserId: userId, }), ).rejects.toThrow(); }); });