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>
150 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|