Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:

ci:
needs: workflow-contract
uses: devnogi/ci-workflows/.github/workflows/node-ci.yml@edc5b2b8ac5270b60227f995ead0dfa60e04b731
uses: devnogi/ci-workflows/.github/workflows/node-ci.yml@75a5268f90bc8b14fa8b56f36cef4a747e2471d9
with:
service-name: react
dockerfile: Dockerfile
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/push-cd-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
production:
needs: workflow-contract
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
uses: devnogi/ci-workflows/.github/workflows/node-prod.yml@edc5b2b8ac5270b60227f995ead0dfa60e04b731
uses: devnogi/ci-workflows/.github/workflows/node-prod.yml@75a5268f90bc8b14fa8b56f36cef4a747e2471d9
with:
service-name: react
deployment-target: prod2
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
"version": "1.0.31",
"version": "1.0.32",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-production-workflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test("production Compose requires the immutable image reference", () => {
test("package and lockfile agree on the next release version", () => {
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.version, "1.0.31");
assert.equal(pkg.version, "1.0.32");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
});
4 changes: 2 additions & 2 deletions scripts/workflow-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const read = (path) =>
"\n",
);
// prettier-ignore
const pin = 'edc5b2b8ac5270b60227f995ead0dfa60e04b731';
const pin = '75a5268f90bc8b14fa8b56f36cef4a747e2471d9';
const ci = read(".github/workflows/pr-verify.yml");
const production = read(".github/workflows/push-cd-prod.yml");

Expand Down Expand Up @@ -68,7 +68,7 @@ test("release version and immutable image contract are fixed", () => {
);
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.version, "1.0.31");
assert.equal(pkg.version, "1.0.32");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
assert.match(
Expand Down
3 changes: 3 additions & 0 deletions src/app/(main)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type { Metadata } from "next";
import HomeLanding from "@/components/seo/HomeLanding";
import { createPageMetadata } from "@/lib/seo/site";

// Docker builds have no gateway; retry a build-time fallback after deployment.
export const revalidate = 300;

export const metadata: Metadata = createPageMetadata({
title: "마비노기 경매장·시세·아이템 정보",
description:
Expand Down
1 change: 1 addition & 0 deletions src/app/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe("public sitemap XML", () => {
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith(
"/dcs/api/posts/sitemap?limit=1000&cursor=99",
{ timeoutMs: 20000 },
);
expect(xml).toContain("/community/100</loc>");
expect(xml).toContain("<lastmod>2026-11-13T09:00:00+09:00</lastmod>");
Expand Down
1 change: 1 addition & 0 deletions src/components/seo/PopularItemLinks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ it("renders links only for exact known catalogue entries", async () => {
expect(renderToStaticMarkup(await PopularItemLinks())).toContain(
itemPath(item),
);
expect(getItemSeo).toHaveBeenCalledWith(expect.any(String), 300);
jest.mocked(getItemSeo).mockResolvedValue(null);
expect(await PopularItemLinks()).toBeNull();
});
Expand Down
1 change: 1 addition & 0 deletions src/components/seo/PopularItemLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default async function PopularItemLinks() {
topCategory: item.itemTopCategory,
subCategory: item.itemSubCategory,
}),
300,
),
),
);
Expand Down
16 changes: 16 additions & 0 deletions src/lib/items/data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ beforeEach(() => {
});
afterEach(() => jest.restoreAllMocks());

it("keeps detail lookups uncached and only opts homepage lookups into five-minute revalidation", async () => {
jest
.mocked(fetch)
.mockResolvedValue(response({ item, seoEligible: true, lastTrade: null }));
await getItemSeo(encodeItemKey(item));
expect(jest.mocked(fetch).mock.calls[0][1]).toMatchObject({
cache: "no-store",
});
expect(jest.mocked(fetch).mock.calls[0][1]).not.toHaveProperty("next");
await getItemSeo(encodeItemKey(item), 300);
expect(jest.mocked(fetch).mock.calls[1][1]).toMatchObject({
next: { revalidate: 300 },
});
expect(jest.mocked(fetch).mock.calls[1][1]).not.toHaveProperty("cache");
});

it("does not confuse missing catalogue items and transient errors", async () => {
jest.mocked(fetch).mockResolvedValueOnce(response({}, 404));
await expect(getItemSeo(encodeItemKey(item))).resolves.toBeNull();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/items/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,14 @@ export async function getItemStatistics(
}

export const getItemSeo = cache(
async (key: string): Promise<ItemSeoData | null> => {
async (key: string, revalidate?: 300): Promise<ItemSeoData | null> => {
const identity = decodeItemKey(key);
if (!identity) return null;
try {
const params = new URLSearchParams({ ...identity });
const result = await getPublicItemJson<ItemSeoData>(
`/oab/api/item-infos/seo?${params}`,
revalidate,
);
if (
!result?.item ||
Expand Down
7 changes: 5 additions & 2 deletions src/lib/seo/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ export class PublicApiError extends Error {
}

/** Public rendering must not inherit a visitor's session or cache private data. */
export async function publicJson<T>(path: string): Promise<T> {
export async function publicJson<T>(
path: string,
{ timeoutMs = 5000 }: { timeoutMs?: 5000 | 20000 } = {},
): Promise<T> {
const response = await fetch(new URL(path, selectServerGatewayUrl()), {
cache: "no-store",
credentials: "omit",
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw new PublicApiError(response.status);
const payload = await response.json();
Expand Down
92 changes: 92 additions & 0 deletions src/lib/seo/sitemaps-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { publicJson } from "./public-api";

const projection = {
items: [{ id: 1, updatedAt: null }],
nextCursor: null,
hasNext: false,
};
const request = () => new Request("https://www.memonogi.com/sitemap-posts.xml");

function delayedFetch(delay: number) {
jest.spyOn(globalThis, "fetch").mockImplementation(
(_url, init) =>
new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
resolve(
new Response(JSON.stringify({ success: true, data: projection })),
),
delay,
);
init?.signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(init.signal?.reason);
},
{ once: true },
);
}),
);
}

beforeEach(() => {
jest.resetModules();
jest.useFakeTimers();
process.env.GATEWAY_URL = "http://synthetic-gateway.invalid";
// Native AbortSignal timers are outside Jest's clock; retain real abort events.
jest.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => {
const controller = new AbortController();
setTimeout(
() =>
controller.abort(new DOMException("Deadline exceeded", "TimeoutError")),
milliseconds,
);
return controller.signal;
});
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});

it("keeps ordinary public requests at five seconds", async () => {
delayedFetch(8000);
const result = publicJson("/dcs/api/posts/feed").catch((error) => error);
await jest.advanceTimersByTimeAsync(5000);
expect(await result).toMatchObject({ name: "TimeoutError" });
expect(AbortSignal.timeout).toHaveBeenCalledWith(5000);
});

it("accepts a slow successful sitemap projection and caches complete XML", async () => {
delayedFetch(8000);
const { sitemapResponse } = await import("./sitemaps");
const pending = sitemapResponse("posts", request());
await jest.advanceTimersByTimeAsync(8000);
const response = await pending;
expect(response.status).toBe(200);
expect(await response.text()).toContain("/community/1</loc>");
expect(AbortSignal.timeout).toHaveBeenCalledWith(20000);
expect((await sitemapResponse("posts", request())).status).toBe(200);
expect(fetch).toHaveBeenCalledTimes(1);
});

it("aborts at twenty seconds without caching an empty result and retries successfully", async () => {
delayedFetch(25000);
const { sitemapResponse } = await import("./sitemaps");
const pending = sitemapResponse("posts", request());
await jest.advanceTimersByTimeAsync(20000);
const response = await pending;
expect(response.status).toBe(503);
expect(await response.text()).not.toContain("<urlset");
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ success: true, data: projection })),
);
const retry = await sitemapResponse("posts", request());
expect(retry.status).toBe(200);
expect(await retry.text()).toContain("/community/1</loc>");
expect((await sitemapResponse("posts", request())).status).toBe(200);
expect(fetch).toHaveBeenCalledTimes(2);
});
5 changes: 4 additions & 1 deletion src/lib/seo/sitemaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ function validCursor(kind: Kind, cursor: string) {
async function projection(kind: Kind, cursor: string): Promise<Projection> {
const params = new URLSearchParams({ limit: "1000" });
if (cursor) params.set("cursor", cursor);
const data = await publicJson<Projection>(`${sources[kind]}?${params}`);
// Catalogue projections may exceed the interactive page's five-second budget.
const data = await publicJson<Projection>(`${sources[kind]}?${params}`, {
timeoutMs: 20000,
});
if (
!Array.isArray(data?.items) ||
data.items.length > 1000 ||
Expand Down
Loading