import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { and, eq } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { getDb } from "./db.ts"; import { users, activities, follows } from "@trails-cool/db/schema/journal"; // Opt-in: talks to real Postgres (and exercises the real Fedify // dispatcher stack via handleFederationRequest). Same convention as the // other *.integration.test.ts files. const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; const USERNAME = `outbox-user-${Date.now()}`; let userId: string; async function seed() { const db = getDb(); userId = randomUUID(); await db.insert(users).values({ id: userId, email: `${USERNAME}@example.test`, username: USERNAME, domain: "test.local", profileVisibility: "public", }); // 3 public, 1 unlisted, 1 private — only the 3 public ones federate. const mk = (n: number, visibility: "public" | "unlisted" | "private") => ({ id: randomUUID(), ownerId: userId, name: `Activity ${n}`, visibility, distance: 1000 * n, createdAt: new Date(Date.now() - n * 60_000), }); await db .insert(activities) .values([mk(1, "public"), mk(2, "public"), mk(3, "public"), mk(4, "unlisted"), mk(5, "private")]); } describe.runIf(runIntegration)("federation outbox (integration)", () => { beforeAll(async () => { process.env.FEDERATION_ENABLED = "true"; process.env.ORIGIN ??= "http://localhost:3000"; await seed(); }); afterAll(async () => { const db = getDb(); await db.delete(activities).where(eq(activities.ownerId, userId)); await db.delete(follows).where(eq(follows.followedUserId, userId)); await db.delete(users).where(eq(users.id, userId)); }); // Mirrors routes/users.$username.outbox.ts: route-level visibility // gate first (Fedify's collection-level response doesn't consult the // page dispatcher), then delegate to Fedify. async function fetchOutbox(path: string): Promise { const { handleFederationRequest, isFederatableUser } = await import("./federation.server.ts"); if (!(await isFederatableUser(USERNAME))) { return new Response("Not Found", { status: 404 }); } return handleFederationRequest( new Request(`http://localhost:3000${path}`, { headers: { accept: "application/activity+json" }, }), ); } it("serves an OrderedCollection counting only public activities", async () => { const res = await fetchOutbox(`/users/${USERNAME}/outbox`); expect(res.status).toBe(200); const body = await res.json(); expect(body.type).toBe("OrderedCollection"); expect(body.totalItems).toBe(3); expect(body.first).toBeDefined(); }); it("pages contain Create(Note) items, public only, newest first", async () => { const res = await fetchOutbox(`/users/${USERNAME}/outbox?cursor=0`); expect(res.status).toBe(200); const body = await res.json(); const items = Array.isArray(body.orderedItems) ? body.orderedItems : [body.orderedItems]; expect(items).toHaveLength(3); for (const item of items) { expect(item.type).toBe("Create"); expect(item.object.type).toBe("Note"); expect(item.object.attributedTo).toContain(`/users/${USERNAME}`); } const names = items.map((i: { object: { content: string } }) => i.object.content); expect(names[0]).toContain("Activity 1"); // newest first expect(names.join()).not.toContain("Activity 4"); // unlisted excluded expect(names.join()).not.toContain("Activity 5"); // private excluded }); it("404s the outbox of a private profile", async () => { const db = getDb(); await db.update(users).set({ profileVisibility: "private" }).where(eq(users.id, userId)); const res = await fetchOutbox(`/users/${USERNAME}/outbox`); expect(res.status).toBe(404); await db.update(users).set({ profileVisibility: "public" }).where(eq(users.id, userId)); }); it("serves a public activity's Note at its IRI (dereferenceable)", async () => { const { handleFederationRequest } = await import("./federation.server.ts"); const db = getDb(); const [row] = await db .select({ id: activities.id }) .from(activities) .where(and(eq(activities.ownerId, userId), eq(activities.visibility, "public"))) .limit(1); const res = await handleFederationRequest( new Request(`http://localhost:3000/activities/${row!.id}`, { headers: { accept: "application/activity+json" }, }), ); expect(res.status).toBe(200); const note = await res.json(); expect(note.type).toBe("Note"); expect(note.id).toBe(`http://localhost:3000/activities/${row!.id}`); expect(note.attributedTo).toContain(`/users/${USERNAME}`); }); it("404s the Note of a private activity", async () => { const { handleFederationRequest } = await import("./federation.server.ts"); const db = getDb(); const [priv] = await db .select({ id: activities.id }) .from(activities) .where(and(eq(activities.ownerId, userId), eq(activities.visibility, "private"))) .limit(1); const res = await handleFederationRequest( new Request(`http://localhost:3000/activities/${priv!.id}`, { headers: { accept: "application/activity+json" }, }), ); expect(res.status).toBe(404); }); it("lists accepted remote followers as the delivery audience", async () => { const db = getDb(); const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts"); await db.insert(follows).values([ { id: randomUUID(), followerActorIri: "https://other.example/users/accepted", followedActorIri: `http://localhost:3000/users/${USERNAME}`, followedUserId: userId, acceptedAt: new Date(), }, { id: randomUUID(), followerActorIri: "https://other.example/users/pending", followedActorIri: `http://localhost:3000/users/${USERNAME}`, followedUserId: userId, acceptedAt: null, }, ]); const audience = await listAcceptedRemoteFollowers(userId); expect(audience).toEqual(["https://other.example/users/accepted"]); }); });