fix(public): distinguish unavailable data from empty results and missing pages
CI / check (push) Successful in 56s
CI / deploy (push) Successful in 1m30s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-11 00:39:53 +02:00
1 parent db4acbb46e
commit 445ef13846
16 files changed
+608 -169

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
import { createTranslator, NextIntlClientProvider } from "next-intl";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GuildThread from "@/app/(site)/guilds/[id]/forum/[threadId]/page";
import GuildForum from "@/app/(site)/guilds/[id]/forum/page";
import GuildDetail from "@/app/(site)/guilds/[id]/page";
import messages from "@/messages/en.json";
const state = vi.hoisted(() => ({
fail: new Set<string>(),
rows: {} as Record<string, unknown[]>,
log: vi.fn(),
}));
vi.mock("next/navigation", () => ({
notFound: () => {
throw Error("NOT_FOUND");
},
}));
vi.mock("@/lib/auth", () => ({ auth: async () => null }));
vi.mock("@/actions/social", () => ({ replyToThread: async () => {} }));
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
vi.mock("@/lib/cache", () => ({
cached: (_key: string, _ttl: number, load: () => unknown) => load(),
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: async () => "7" },
}));
vi.mock("next-intl/server", () => ({
getTranslations: async (
namespace:
| "pages.staff"
| "pages.rankings"
| "pages.photos"
| "pages.guilds"
| "pages.guild"
| "pages.guildForum"
| "pages.guildForumThread",
) => createTranslator({ locale: "en", messages, namespace }),
}));
vi.mock("@/components/shared/profile-image", () => ({
ProfileImage: () => null,
}));
vi.mock("@/components/shared/user-avatar-thumbnail", () => ({
UserAvatarThumbnail: () => null,
}));
vi.mock("@/components/public/photo-lightbox", () => ({
PhotoLightbox: () => null,
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { getTableName } = await import("drizzle-orm");
return {
...schema,
db: {
select: () => {
let name = "";
const load = async () => {
if (state.fail.has(name)) throw Error("fixture unavailable");
if (state.rows[name]) return state.rows[name];
return name === "users"
? [
{
username: "Available member",
look: "",
rank: 7,
motto: "Hello",
credits: 100,
online: "1",
},
]
: [];
};
const query = {
from: (table: Parameters<typeof getTableName>[0]) => {
name = getTableName(table);
return query;
},
where: () => query,
orderBy: () => query,
limit: () => query,
// biome-ignore lint/suspicious/noThenProperty: models Drizzle lazy query execution
then: (...args: Parameters<ReturnType<typeof load>["then"]>) =>
load().then(...args),
catch: (...args: Parameters<ReturnType<typeof load>["catch"]>) =>
load().catch(...args),
};
return query;
},
},
};
});
import Guilds from "@/app/(site)/guilds/page";
import Photos from "@/app/(site)/photos/page";
import Rankings from "@/app/(site)/rankings/page";
import Staff from "@/app/(site)/staff/page";
import { publicReadFailure } from "@/lib/services/public-read";
const copy = {
loadError: "Section unavailable",
retry: "Retry",
unavailable: "Unavailable",
};
async function render(page: () => Promise<React.ReactNode>) {
return renderToStaticMarkup(
<NextIntlClientProvider
locale="en"
messages={{ ...messages, publicData: copy }}
>
{await page()}
</NextIntlClientProvider>,
);
}
describe("public page read failures", () => {
beforeEach(() => {
state.fail.clear();
state.rows = {};
state.log.mockClear();
});
it.each([
[Rankings, "users"],
[Photos, "camera_web"],
[Guilds, "guilds"],
] as const)(
"shows failure and retry instead of empty results",
async (page, table) => {
state.fail.add(table);
const html = await render(page);
expect(html).toContain('role="alert"');
expect(html).toContain("Section unavailable");
expect(html).toContain("Retry");
expect(state.log).toHaveBeenCalled();
},
);
it.each([Photos, Guilds])(
"keeps genuine empty results distinct",
async (page) => {
const html = await render(page);
expect(html).not.toContain('role="alert"');
expect(state.log).not.toHaveBeenCalled();
},
);
it("preserves staff members when team metadata fails", async () => {
state.fail.add("website_teams");
const html = await render(Staff);
expect(html).toContain("Available member");
expect(html).toContain("Section unavailable");
expect(state.log).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ section: "staff.teams" }),
);
});
it("returns null and logs section context without changing successful values", async () => {
const fail = publicReadFailure("home.news");
const error = Error("unavailable");
expect(await Promise.reject(error).catch(fail)).toBeNull();
expect(await Promise.resolve([]).catch(fail)).toEqual([]);
expect(await Promise.resolve(0).catch(fail)).toBe(0);
expect(state.log).toHaveBeenCalledTimes(1);
});
});
const detailPages = [
() => GuildDetail({ params: Promise.resolve({ id: "1" }) }),
() =>
GuildForum({
params: Promise.resolve({ id: "1" }),
searchParams: Promise.resolve({}),
}),
() =>
GuildThread({
params: Promise.resolve({ id: "1", threadId: "2" }),
searchParams: Promise.resolve({}),
}),
];
describe("guild detail failures", () => {
beforeEach(() => {
state.fail.clear();
state.log.mockClear();
state.rows = {
guilds: [
{
id: 1,
name: "Available guild",
description: "Community",
badge: "",
userId: 7,
dateCreated: 1000,
},
],
guilds_forums_threads: [
{
id: 2,
subject: "Available thread",
locked: 0,
pinned: 0,
openerId: 7,
postsCount: 1,
createdAt: 1000,
updatedAt: 1000,
},
],
guilds_forums_comments: [
{ id: 3, userId: 7, message: "Available post", createdAt: 1000 },
],
};
});
it.each(detailPages)(
"shows database failures without claiming a missing guild",
async (page) => {
state.fail.add("guilds");
const html = await render(page);
expect(html).toContain("Section unavailable");
},
);
it.each(detailPages)("keeps real missing guilds as 404", async (page) => {
state.rows.guilds = [];
await expect(render(page)).rejects.toThrow("NOT_FOUND");
});
it("keeps guild metadata when members fail and avoids a false zero", async () => {
state.fail.add("guilds_members");
const html = await render(detailPages[0]);
expect(html).toContain("Available guild");
expect(html).toContain("Section unavailable");
expect(html).toContain("—");
});
it("keeps forum threads when author names fail", async () => {
state.fail.add("users");
const html = await render(detailPages[1]);
expect(html).toContain("Available thread");
expect(html).toContain("Section unavailable");
});
it("keeps the thread header when posts fail", async () => {
state.fail.add("guilds_forums_comments");
const html = await render(detailPages[2]);
expect(html).toContain("Available thread");
expect(html).toContain("Section unavailable");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ fail: false }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/services/news-cache", () => ({
cacheNews: async () => {
if (state.fail) throw Error("unavailable");
return [];
},
}));
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
import { getNewsList } from "./news-list";
beforeEach(() => {
state.fail = false;
});
it("preserves an empty successful list in strict mode", async () => {
expect(await getNewsList(4, { throwOnError: true })).toEqual([]);
});
it("lets the homepage distinguish a failed news section", async () => {
state.fail = true;
await expect(getNewsList(4, { throwOnError: true })).rejects.toThrow(
"unavailable",
);
});
it("preserves the legacy fallback for other callers", async () => {
state.fail = true;
expect(await getNewsList(4)).toEqual([]);
});
+6 -2
View File
@@ -26,12 +26,15 @@ const CACHE_KEY = "news_list";
const FETCH_LIMIT = 30;
/**
* Shared, cached news list used by both the homepage and the news archive so
* Shared, cached news preview list used by the homepage and other widgets so
* a single "news_list" cache entry serves both routes. Returns at most
* `limit` rows with `createdAt` normalized to a Date (the cache round-trip
* serializes timestamps to ISO strings).
*/
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
export async function getNewsList(
limit: number,
options: { throwOnError?: boolean } = {},
): Promise<NewsListItem[]> {
try {
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
db
@@ -62,6 +65,7 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
}))
.slice(0, limit);
} catch (error) {
if (options.throwOnError) throw error;
logger.error("Public news query failed", { module: "news", error });
return [];
}
+14 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
fail: false,
friends: [] as { id: number; username: string; online: string }[],
}));
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
@@ -30,7 +31,8 @@ vi.mock("@/lib/db", async () => {
innerJoin: () => chain,
orderBy: () => chain,
limit: () => chain,
catch: () => Promise.resolve(rows),
catch: (fallback: () => unknown) =>
Promise.resolve(state.fail ? fallback() : rows),
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
then: (resolve: (value: unknown[]) => unknown) =>
Promise.resolve(rows).then(resolve),
@@ -46,6 +48,17 @@ import { loadUserDashboard } from "./user-dashboard";
describe("dashboard friendships", () => {
beforeEach(() => {
state.friends = [];
state.fail = false;
});
it("marks unavailable sections instead of presenting them as empty data", async () => {
state.fail = true;
const data = await loadUserDashboard(1);
expect(data).toMatchObject({
friendsUnavailable: true,
badgesUnavailable: true,
userSettingsUnavailable: true,
referralsRows: null,
});
});
it("counts each friend once when both directional rows are returned", async () => {
const wait = { id: 2, username: "wait", online: "1" };
+14 -10
View File
@@ -1,3 +1,4 @@
import { publicReadFailure } from "./public-read";
import "server-only";
import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm";
import {
@@ -65,7 +66,7 @@ export async function loadUserDashboard(userId: number) {
.from(Rooms)
.orderBy(desc(Rooms.id))
.limit(6)
.catch(() => null),
.catch(publicReadFailure("dashboard.rooms")),
db
.select({
badgeCode: UsersBadges.badgeCode,
@@ -74,7 +75,7 @@ export async function loadUserDashboard(userId: number) {
.from(UsersBadges)
.where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0)))
.orderBy(asc(UsersBadges.slotId))
.catch(() => []),
.catch(publicReadFailure("dashboard.badges")),
db
.select({
achievementScore: UsersSettings.achievementScore,
@@ -83,19 +84,19 @@ export async function loadUserDashboard(userId: number) {
.from(UsersSettings)
.where(eq(UsersSettings.userId, userId))
.limit(1)
.catch(() => []),
.catch(publicReadFailure("dashboard.settings")),
db
.select({ value: count() })
.from(MessengerOffline)
.where(eq(MessengerOffline.userId, userId))
.catch(() => null),
.catch(publicReadFailure("dashboard.unread")),
db
.select({ referralsTotal: UserReferrals.referralsTotal })
.from(UserReferrals)
.where(eq(UserReferrals.userId, userId))
.orderBy(desc(UserReferrals.id))
.limit(1)
.catch(() => []),
.catch(publicReadFailure("dashboard.referrals")),
db
.select({
id: User.id,
@@ -124,7 +125,7 @@ export async function loadUserDashboard(userId: number) {
eq(MessengerFriendships.userTwoId, userId),
),
)
.catch(() => []),
.catch(publicReadFailure("dashboard.friends")),
db
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
.from(UsersCurrency)
@@ -134,13 +135,13 @@ export async function loadUserDashboard(userId: number) {
inArray(UsersCurrency.type, [0, 5]),
),
)
.catch(() => null),
.catch(publicReadFailure("dashboard.currency")),
]);
// Each friendship can have two directional rows; expose users, not edges.
const uniqueFriends = [
...new Map(
friends
(friends ?? [])
.filter((friend) => friend.id !== userId)
.map((friend) => [friend.id, friend]),
).values(),
@@ -155,8 +156,11 @@ export async function loadUserDashboard(userId: number) {
currencySettings,
alertRaw,
recentRooms,
badges,
userSettingsRows,
badges: badges ?? [],
badgesUnavailable: badges === null,
userSettingsRows: userSettingsRows ?? [],
userSettingsUnavailable: userSettingsRows === null,
friendsUnavailable: friends === null,
friendCountRows: [{ value: uniqueFriends.length }],
unreadCountRows,
referralsRows,