trails/apps/journal/app/lib/follow.integration.test.ts
Ullrich Schäfer 5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00

150 lines
6.2 KiB
TypeScript

import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
import { users, follows } from "@trails-cool/db/schema/journal";
import {
followUser,
unfollowUser,
getFollowState,
countFollowers,
countFollowing,
countPendingFollowRequests,
listPendingFollowRequests,
approveFollowRequest,
rejectFollowRequest,
} from "./follow.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
// demo-bot.integration.test.ts.
const runIntegration = process.env.FOLLOW_INTEGRATION === "1";
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",
});
return id;
}
async function wipe() {
const db = getDb();
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
}
describe.skipIf(!runIntegration)("follow.server integration", () => {
beforeAll(async () => {
const db = getDb();
await db.execute(sql`SELECT 1`);
});
afterEach(wipe);
it("follow + unfollow cycle on a public profile", async () => {
const a = await makeUser({ username: `f_a_${Date.now()}` });
const b = await makeUser({ username: `f_b_${Date.now()}` });
const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!;
const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!;
expect(await getFollowState(a, bRow.username)).toBeNull();
const s1 = await followUser(a, bRow.username);
expect(s1).toEqual({ following: true, pending: false });
expect(await countFollowers(b)).toBe(1);
expect(await countFollowing(a)).toBe(1);
// Idempotent
const s2 = await followUser(a, bRow.username);
expect(s2).toEqual({ following: true, pending: false });
expect(await countFollowers(b)).toBe(1);
const s3 = await unfollowUser(a, bRow.username);
expect(s3).toEqual({ following: false, pending: false });
expect(await countFollowers(b)).toBe(0);
expect(await getFollowState(a, bRow.username)).toBeNull();
// Idempotent
const s4 = await unfollowUser(a, bRow.username);
expect(s4).toEqual({ following: false, pending: false });
// touch aRow so the variable is read (lint hygiene)
expect(aRow.username.startsWith("f_a_")).toBe(true);
});
it("creates a Pending follow against a private profile (not a refusal)", async () => {
const a = await makeUser({ username: `f_pa_${Date.now()}` });
const b = await makeUser({ username: `f_pb_${Date.now()}`, profileVisibility: "private" });
const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!;
const s = await followUser(a, bRow.username);
expect(s).toEqual({ following: false, pending: true });
// Pending is excluded from accepted-only counts.
expect(await countFollowers(b)).toBe(0);
expect(await countFollowing(a)).toBe(0);
});
it("refuses self-follow", async () => {
const a = await makeUser({ username: `f_self_${Date.now()}` });
const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!;
await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" });
});
it("approve flips Pending → Accepted; reject deletes the request", async () => {
const a = await makeUser({ username: `f_apr_${Date.now()}` });
const b = await makeUser({ username: `f_apb_${Date.now()}`, profileVisibility: "private" });
const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!;
await followUser(a, bRow.username);
expect(await countPendingFollowRequests(b)).toBe(1);
const reqs = await listPendingFollowRequests(b);
expect(reqs.length).toBe(1);
const reqId = reqs[0]!.id;
const approved = await approveFollowRequest(b, reqId);
expect(approved).toBe(true);
expect(await countPendingFollowRequests(b)).toBe(0);
expect(await countFollowers(b)).toBe(1);
expect(await getFollowState(a, bRow.username)).toEqual({ following: true, pending: false });
// Idempotent: approving again is a no-op.
expect(await approveFollowRequest(b, reqId)).toBe(false);
// Reject path: a fresh request from a 3rd user, B rejects.
const c = await makeUser({ username: `f_apc_${Date.now()}` });
await followUser(c, bRow.username);
const reqs2 = await listPendingFollowRequests(b);
expect(reqs2.length).toBe(1);
expect(await rejectFollowRequest(b, reqs2[0]!.id)).toBe(true);
expect(await countPendingFollowRequests(b)).toBe(0);
expect(await getFollowState(c, bRow.username)).toBeNull();
});
it("approve/reject is owner-bound (other users can't approve someone else's request)", async () => {
const a = await makeUser({ username: `f_obA_${Date.now()}` });
const b = await makeUser({ username: `f_obB_${Date.now()}`, profileVisibility: "private" });
const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!;
const c = await makeUser({ username: `f_obC_${Date.now()}` });
await followUser(a, bRow.username);
const reqs = await listPendingFollowRequests(b);
// C tries to approve B's incoming request — should be a no-op.
expect(await approveFollowRequest(c, reqs[0]!.id)).toBe(false);
expect(await countPendingFollowRequests(b)).toBe(1);
});
it("404s on unknown username", async () => {
const a = await makeUser({ username: `f_404_${Date.now()}` });
await expect(followUser(a, "no_such_user_xyz")).rejects.toMatchObject({ code: "user_not_found" });
expect(await countFollowing(a)).toBe(0);
// suppress lint by using afterEach effect indirectly
expect(typeof a).toBe("string");
// also touch follows table so the import isn't unused
const db = getDb();
const rows = await db.select().from(follows).where(eq(follows.followerId, a));
expect(rows.length).toBe(0);
});
});