From 37c661df380ef2400f7743ff329b922122866b8b Mon Sep 17 00:00:00 2001 From: RissRIce Date: Sat, 1 Aug 2026 10:20:16 -0600 Subject: [PATCH] fix(social): exclude inactive follow edges from rankings --- apps/web/lib/social.test.ts | 64 +++++++++++++++++++++++++++++++++++++ apps/web/lib/social.ts | 9 ++++-- 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 apps/web/lib/social.test.ts diff --git a/apps/web/lib/social.test.ts b/apps/web/lib/social.test.ts new file mode 100644 index 0000000..5d849d0 --- /dev/null +++ b/apps/web/lib/social.test.ts @@ -0,0 +1,64 @@ +import { createClient, type Client, type InStatement } from "@libsql/client"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getMostFollowed, getMostFollowing } from "./social"; + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })); + +vi.mock("server-only", () => ({})); +vi.mock("./db", () => ({ + sqlClient: { execute: mocks.execute }, +})); + +let client: Client; + +describe("follow leaderboards", () => { + beforeAll(async () => { + client = createClient({ url: "file::memory:" }); + mocks.execute.mockImplementation((statement: InStatement) => client.execute(statement)); + await client.execute(`CREATE TABLE users ( + id TEXT PRIMARY KEY, + display_name TEXT, + status TEXT NOT NULL + )`); + await client.execute(`CREATE TABLE follows ( + follower_id TEXT NOT NULL, + followee_id TEXT NOT NULL, + PRIMARY KEY (follower_id, followee_id) + )`); + }); + + beforeEach(async () => { + await client.execute("DELETE FROM follows"); + await client.execute("DELETE FROM users"); + await client.execute(`INSERT INTO users (id, display_name, status) VALUES + ('alice', 'Alice', 'active'), + ('bob', 'Bob', 'active'), + ('suspended', 'Suspended', 'suspended'), + ('deleted', 'Deleted', 'deleted')`); + await client.execute(`INSERT INTO follows (follower_id, followee_id) VALUES + ('bob', 'alice'), + ('suspended', 'alice'), + ('deleted', 'alice'), + ('alice', 'bob'), + ('alice', 'suspended'), + ('alice', 'deleted')`); + }); + + afterAll(() => { + client.close(); + }); + + it("excludes inactive followers from the most-followed counts", async () => { + const rows = await getMostFollowed(); + + expect(rows.find((row) => row.userId === "alice")?.count).toBe(1); + expect(rows.some((row) => row.userId === "suspended" || row.userId === "deleted")).toBe(false); + }); + + it("excludes inactive followees from the most-following counts", async () => { + const rows = await getMostFollowing(); + + expect(rows.find((row) => row.userId === "alice")?.count).toBe(1); + expect(rows.some((row) => row.userId === "suspended" || row.userId === "deleted")).toBe(false); + }); +}); diff --git a/apps/web/lib/social.ts b/apps/web/lib/social.ts index 89d1b32..48e2f27 100644 --- a/apps/web/lib/social.ts +++ b/apps/web/lib/social.ts @@ -71,10 +71,13 @@ export type FollowRankRow = { rank: number; userId: string; displayName: string; async function followRanking(kind: "followers" | "following", limit: number): Promise { // followers = ranked by inbound edges (followee_id); following = outbound (follower_id). const groupCol = kind === "followers" ? "followee_id" : "follower_id"; + const countedCol = kind === "followers" ? "follower_id" : "followee_id"; const r = await sqlClient.execute({ - sql: `SELECT f.${groupCol} AS uid, COUNT(*) AS c, u.display_name AS name - FROM follows f JOIN users u ON u.id = f.${groupCol} - WHERE u.status = 'active' + sql: `SELECT f.${groupCol} AS uid, COUNT(*) AS c, ranked.display_name AS name + FROM follows f + JOIN users ranked ON ranked.id = f.${groupCol} + JOIN users counted ON counted.id = f.${countedCol} + WHERE ranked.status = 'active' AND counted.status = 'active' GROUP BY f.${groupCol} ORDER BY c DESC, name ASC LIMIT ?`,