fix(news): restore publication flow and deduplicate dashboard friends
This commit is contained in:
1 parent
ef94646d60
commit
9b0ea2fb16
22 files changed
+638
-195
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
revision: "0",
|
||||
values: new Map<string, unknown>(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
status: "ready",
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: state }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cached: async (key: string, _ttl: number, query: () => Promise<unknown>) => {
|
||||
if (state.values.has(key)) return state.values.get(key);
|
||||
const result = await query();
|
||||
state.values.set(key, result);
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
import { cacheNews, invalidateNewsCache } from "./news-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
state.values.clear();
|
||||
state.revision = "0";
|
||||
state.status = "ready";
|
||||
state.get.mockReset().mockImplementation(async () => state.revision);
|
||||
state.set
|
||||
.mockReset()
|
||||
.mockImplementation(async (_key: string, value: string) => {
|
||||
state.revision = value;
|
||||
});
|
||||
});
|
||||
it("invalidates a previously cached public list", async () => {
|
||||
const query = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(["new article"]);
|
||||
expect(await cacheNews("list", 60000, query)).toEqual([]);
|
||||
await invalidateNewsCache();
|
||||
expect(await cacheNews("list", 60000, query)).toEqual(["new article"]);
|
||||
});
|
||||
it("does not let a stale in-flight read replace a newer revision", async () => {
|
||||
let finish!: (value: string[]) => void;
|
||||
const old = cacheNews(
|
||||
"list",
|
||||
60000,
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
await invalidateNewsCache();
|
||||
expect(await cacheNews("list", 60000, async () => ["new"])).toEqual(["new"]);
|
||||
finish([]);
|
||||
await old;
|
||||
expect(await cacheNews("list", 60000, async () => ["wrong"])).toEqual([
|
||||
"new",
|
||||
]);
|
||||
});
|
||||
it("reads fresh data when Redis is unavailable", async () => {
|
||||
state.get.mockRejectedValue(Error("offline"));
|
||||
expect(await cacheNews("list", 60000, async () => ["fresh"])).toEqual([
|
||||
"fresh",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const REVISION_KEY = "cms:news:revision";
|
||||
export async function cacheNews<T>(
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
query: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!redis || redis.status === "end") return query();
|
||||
let revision: string;
|
||||
try {
|
||||
revision = (await redis.get(REVISION_KEY)) ?? "0";
|
||||
} catch {
|
||||
return query();
|
||||
}
|
||||
return cached(`news:${revision}:${key}`, ttlMs, query);
|
||||
}
|
||||
export async function invalidateNewsCache(): Promise<void> {
|
||||
if (!redis || redis.status === "end") return;
|
||||
try {
|
||||
await redis.set(REVISION_KEY, randomUUID());
|
||||
} catch (error) {
|
||||
logger.error("News saved but public cache invalidation failed", {
|
||||
module: "news",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
|
||||
export interface NewsListItem {
|
||||
@@ -32,7 +33,7 @@ const FETCH_LIMIT = 30;
|
||||
*/
|
||||
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||
try {
|
||||
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
||||
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
||||
db
|
||||
.select({
|
||||
slug: WebsiteArticles.slug,
|
||||
@@ -60,7 +61,8 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||
createdAt: a.createdAt ? new Date(a.createdAt) : null,
|
||||
}))
|
||||
.slice(0, limit);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error("Public news query failed", { module: "news", error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
friends: [] as { id: number; username: string; online: string }[],
|
||||
}));
|
||||
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
|
||||
vi.mock("./site-settings", () => ({
|
||||
siteSettings: {
|
||||
get: async (_key: string, fallback: string) => fallback,
|
||||
getMany: async () => ({}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
return {
|
||||
...schema,
|
||||
db: {
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
let rows: unknown[] = [];
|
||||
const chain = {
|
||||
from: (table: unknown) => {
|
||||
if (table === schema.MessengerFriendships)
|
||||
rows =
|
||||
"value" in fields
|
||||
? [{ value: state.friends.length }]
|
||||
: state.friends;
|
||||
return chain;
|
||||
},
|
||||
where: () => chain,
|
||||
innerJoin: () => chain,
|
||||
orderBy: () => chain,
|
||||
limit: () => chain,
|
||||
catch: () => Promise.resolve(rows),
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
|
||||
then: (resolve: (value: unknown[]) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve),
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { loadUserDashboard } from "./user-dashboard";
|
||||
|
||||
describe("dashboard friendships", () => {
|
||||
beforeEach(() => {
|
||||
state.friends = [];
|
||||
});
|
||||
it("counts each friend once when both directional rows are returned", async () => {
|
||||
const wait = { id: 2, username: "wait", online: "1" };
|
||||
const other = { id: 3, username: "YouKnowMe", online: "1" };
|
||||
state.friends = [wait, other, { ...wait }, { ...other }];
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends.map((friend) => friend.id)).toEqual([2, 3]);
|
||||
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||
});
|
||||
it("preserves single-direction and offline friendships", async () => {
|
||||
state.friends = [
|
||||
{ id: 2, username: "wait", online: "1" },
|
||||
{ id: 3, username: "offline", online: "0" },
|
||||
];
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends).toEqual(state.friends);
|
||||
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||
});
|
||||
it("excludes self friendships", async () => {
|
||||
state.friends = [{ id: 1, username: "me", online: "1" }];
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends).toEqual([]);
|
||||
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||
});
|
||||
it("returns zero for an empty friends list", async () => {
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends).toEqual([]);
|
||||
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
@@ -82,16 +81,6 @@ export async function loadUserDashboard(userId: number) {
|
||||
.where(eq(UsersSettings.userId, userId))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerFriendships)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => [{ value: 0 }]),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
@@ -145,6 +134,15 @@ export async function loadUserDashboard(userId: number) {
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
// Each friendship can have two directional rows; expose users, not edges.
|
||||
const uniqueFriends = [
|
||||
...new Map(
|
||||
friends
|
||||
.filter((friend) => friend.id !== userId)
|
||||
.map((friend) => [friend.id, friend]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
return {
|
||||
userRows,
|
||||
hotelName,
|
||||
@@ -155,10 +153,10 @@ export async function loadUserDashboard(userId: number) {
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
friendCountRows: [{ value: uniqueFriends.length }],
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
friends: uniqueFriends,
|
||||
currencyRows,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user