Compare commits

...
2 Commits
Author SHA1 Message Date
ponas 952a8bca13 web: add tests for lib/ + revalidation api 2026-08-04 03:32:45 +02:00
ponas 6370373f0d web: move to vitest 2026-08-04 03:02:09 +02:00
16 changed files with 1911 additions and 35 deletions
+2 -1
View File
@@ -24,11 +24,12 @@ npm install
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
npm run codegen # regenerate GraphQL types (needs the backend running) npm run codegen # regenerate GraphQL types (needs the backend running)
npm run build npm run build
npm test # vitest (npm run test:watch for watch mode)
``` ```
## Pre-commit hooks ## Pre-commit hooks
[prek](https://github.com/j178/prek) runs ruff lint + format on `dnscms/**/*.py` plus a few sanity hooks. Hooks are configured in [prek.toml](prek.toml). [prek](https://github.com/j178/prek) runs ruff lint + format on `dnscms/**/*.py`, vitest on `web/` changes, plus a few sanity hooks. Hooks are configured in [prek.toml](prek.toml).
```bash ```bash
prek install # registers the git hook prek install # registers the git hook
+11
View File
@@ -27,3 +27,14 @@ exclude = '/migrations/'
id = "ruff-format" id = "ruff-format"
files = '^dnscms/.*\.py$' files = '^dnscms/.*\.py$'
exclude = '/migrations/' exclude = '/migrations/'
[[repos]]
repo = "local"
[[repos.hooks]]
id = "vitest"
name = "vitest"
entry = "npm --prefix web test"
language = "system"
files = '^web/(src/.*\.(ts|tsx)|package\.json|vitest\.config\.mts)$'
pass_filenames = false
+1194 -9
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "node --test src/lib/*.test.ts", "test": "vitest run",
"test:watch": "vitest",
"codegen": "graphql-codegen", "codegen": "graphql-codegen",
"perf:build": "next build", "perf:build": "next build",
"perf:serve": "next start -p 3100", "perf:serve": "next start -p 3100",
@@ -45,6 +46,7 @@
"eslint-config-next": "16.2.10", "eslint-config-next": "16.2.10",
"lighthouse": "^13.4.0", "lighthouse": "^13.4.0",
"typescript": "^6", "typescript": "^6",
"vitest": "^4.1.10",
"wait-on": "^9.0.10" "wait-on": "^9.0.10"
}, },
"overrides": { "overrides": {
+50
View File
@@ -0,0 +1,50 @@
import type { NextRequest } from "next/server";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
import { revalidateTag } from "next/cache";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
import { POST } from "./route.ts";
const request = (headers: Record<string, string> = {}) =>
new Request("http://localhost/api/revalidate", {
method: "POST",
headers,
}) as unknown as NextRequest;
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("POST /api/revalidate", () => {
it("returns 503 when no secret is configured", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", undefined);
const res = await POST(request({ "x-revalidate-secret": "hemmelig" }));
expect(res.status).toBe(503);
expect(revalidateTag).not.toHaveBeenCalled();
});
it("returns 401 on missing or wrong secret", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
expect((await POST(request())).status).toBe(401);
expect(
(await POST(request({ "x-revalidate-secret": "feil" }))).status
).toBe(401);
expect(revalidateTag).not.toHaveBeenCalled();
});
it("purges the cms tag on the correct secret", async () => {
vi.stubEnv("REVALIDATE_WEBHOOK_SECRET", "hemmelig");
const res = await POST(request({ "x-revalidate-secret": "hemmelig" }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ revalidated: true, tag: CMS_CACHE_TAG });
expect(revalidateTag).toHaveBeenCalledWith(CMS_CACHE_TAG, { expire: 0 });
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
formatHumanReadableList,
formatNorwegianPhoneNumber,
formatPhoneE164,
getSearchPath,
randomElement,
stripHtml,
stripWhitespace,
unique,
} from "./common.ts";
describe("getSearchPath", () => {
it("builds an encoded query path", () => {
expect(getSearchPath("konsert")).toBe("/sok?q=konsert");
expect(getSearchPath("øl & mat")).toBe("/sok?q=%C3%B8l+%26+mat");
});
it("handles an empty query", () => {
expect(getSearchPath("")).toBe("/sok?");
});
});
describe("randomElement", () => {
it("picks a member, undefined for empty", () => {
expect(randomElement([])).toBeUndefined();
expect(randomElement(["a"])).toBe("a");
expect([1, 2, 3]).toContain(randomElement([1, 2, 3]));
});
});
describe("unique", () => {
it("dedupes preserving first-seen order", () => {
expect(unique([1, 2, 2, 3, 1])).toEqual([1, 2, 3]);
expect(unique([])).toEqual([]);
});
});
describe("stripWhitespace", () => {
it("removes all whitespace", () => {
expect(stripWhitespace(" 22 85 32\t00\n")).toBe("22853200");
});
});
describe("stripHtml", () => {
it("removes tags but keeps text", () => {
expect(stripHtml("<p>Hei <b>du</b></p>")).toBe("Hei du");
expect(stripHtml("ingen tagger")).toBe("ingen tagger");
});
});
describe("formatPhoneE164", () => {
it("prefixes +47 on bare 8-digit numbers only", () => {
expect(formatPhoneE164("22 85 32 00")).toBe("+4722853200");
expect(formatPhoneE164("+47 22 85 32 00")).toBe("+4722853200");
expect(formatPhoneE164("12345")).toBe("12345");
});
});
describe("formatNorwegianPhoneNumber", () => {
it("groups +47 numbers, passes others through", () => {
expect(formatNorwegianPhoneNumber("+4722853200")).toBe("228 53 200");
expect(formatNorwegianPhoneNumber("22853200")).toBe("22853200");
expect(formatNorwegianPhoneNumber("+4612345678")).toBe("+4612345678");
});
});
describe("formatHumanReadableList", () => {
it("joins with og", () => {
expect(formatHumanReadableList([])).toBe("");
expect(formatHumanReadableList(["Neuf"])).toBe("Neuf");
expect(formatHumanReadableList(["a", "b"])).toBe("a og b");
expect(formatHumanReadableList(["a", "b", "c"])).toBe("a, b og c");
});
});
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
compareDates,
formatDate,
formatDateRange,
formatExtendedDateTime,
formatOccurrenceMonths,
formatYearMonth,
groupConsecutiveDates,
isConsecutiveDays,
isTodayOrFuture,
toLocalTime,
} from "./date.ts";
// "now" is tirsdag 2026-07-07 12:00 in Oslo (CEST)
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("toLocalTime", () => {
it("converts UTC to Oslo wall clock", () => {
expect(toLocalTime("2026-07-07T18:00:00Z").getHours()).toBe(20);
expect(toLocalTime("2026-01-07T18:00:00Z").getHours()).toBe(19);
});
});
describe("formatDate", () => {
it("formats in Oslo time with Norwegian locale", () => {
expect(formatDate("2026-07-07T18:00:00Z", "dd.MM.yyyy 'kl.' HH:mm")).toBe(
"07.07.2026 kl. 20:00"
);
expect(formatDate("2026-07-07T18:00:00Z", "EEEE")).toBe("tirsdag");
});
});
describe("formatYearMonth", () => {
it("omits year for the current year", () => {
expect(formatYearMonth("2026-07")).toBe("juli");
});
it("includes year for other years", () => {
expect(formatYearMonth("2025-11")).toBe("november 2025");
});
});
describe("formatExtendedDateTime", () => {
it("omits year in the current year", () => {
expect(formatExtendedDateTime("2026-07-07T18:00:00Z")).toBe(
"tirsdag 7. juli kl. 20:00"
);
});
it("includes year for other years", () => {
expect(formatExtendedDateTime("2025-11-01T12:00:00Z")).toBe(
"lørdag 1. november 2025 kl. 13:00"
);
});
it("supports dateOnly and alwaysIncludeYear", () => {
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", true)).toBe(
"tirsdag 7. juli"
);
expect(formatExtendedDateTime("2026-07-07T18:00:00Z", false, true)).toBe(
"tirsdag 7. juli 2026 kl. 20:00"
);
});
});
describe("isTodayOrFuture", () => {
it("is false for yesterday, true for today and later", () => {
expect(isTodayOrFuture("2026-07-06T10:00:00Z")).toBe(false);
expect(isTodayOrFuture("2026-07-07T20:00:00Z")).toBe(true);
expect(isTodayOrFuture("2026-08-01T00:00:00Z")).toBe(true);
});
});
describe("compareDates", () => {
it("sorts ascending", () => {
expect(compareDates("2026-01-01", "2026-01-02")).toBe(-1);
expect(compareDates("2026-01-02", "2026-01-01")).toBe(1);
expect(compareDates("2026-01-01", "2026-01-01")).toBe(0);
});
});
describe("isConsecutiveDays", () => {
it("is true only for adjacent days", () => {
expect(
isConsecutiveDays(new Date("2026-07-07"), new Date("2026-07-08"))
).toBe(true);
expect(
isConsecutiveDays(new Date("2026-07-07"), new Date("2026-07-09"))
).toBe(false);
});
});
describe("groupConsecutiveDates", () => {
it("dedupes and splits on gaps", () => {
expect(
groupConsecutiveDates([
"2026-07-04T20:00:00Z",
"2026-07-03T20:00:00Z",
"2026-07-04T12:00:00Z",
"2026-07-06T20:00:00Z",
])
).toEqual([["2026-07-03", "2026-07-04"], ["2026-07-06"]]);
});
});
describe("formatOccurrenceMonths", () => {
it("collapses consecutive months", () => {
expect(formatOccurrenceMonths([])).toBe("");
expect(formatOccurrenceMonths(["2026-07-01T12:00:00Z"])).toBe("juli 2026");
expect(
formatOccurrenceMonths([
"2026-06-05T18:00:00Z",
"2026-07-10T18:00:00Z",
"2026-08-01T18:00:00Z",
"2026-11-02T18:00:00Z",
])
).toBe("juni august 2026, november 2026");
});
it("spells out both years across a year boundary", () => {
expect(
formatOccurrenceMonths(["2026-12-05T12:00:00Z", "2027-01-05T12:00:00Z"])
).toBe("desember 2026 januar 2027");
});
});
describe("formatDateRange", () => {
it("formats single days, same-month and cross-month ranges", () => {
expect(formatDateRange(["2026-07-03"])).toBe("3. juli");
expect(formatDateRange(["2026-07-03", "2026-07-05", "2026-07-07"])).toBe(
"3.—7. juli"
);
expect(formatDateRange(["2026-06-30", "2026-07-02"])).toBe(
"30. juni—2. juli"
);
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ export function formatDate(date: Date | string | number, formatStr: string) {
export function formatYearMonth(yearMonth: string) { export function formatYearMonth(yearMonth: string) {
// full name of month if year is current year, otherwise name of month + year // full name of month if year is current year, otherwise name of month + year
const parsed = parse(yearMonth, "yyyy-MM", new Date()); const parsed = parse(yearMonth, "yyyy-MM", new Date());
if (parsed.getFullYear === new Date().getFullYear) { if (parsed.getFullYear() === new Date().getFullYear()) {
return formatDate(parsed, "MMMM"); return formatDate(parsed, "MMMM");
} }
return formatDate(parsed, "MMMM yyyy"); return formatDate(parsed, "MMMM yyyy");
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type EventFragment,
getEventPig,
getFutureOccurrences,
getSingularEvents,
organizeEventsByDate,
organizeEventsInCalendar,
sortSingularEvents,
} from "./event.ts";
// "now" is tirsdag 2026-07-07 12:00 in Oslo
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
const makeEvent = (id: string, starts: string[]) => ({
id,
occurrences: starts.map((start, i) => ({
id: `${id}-${i}`,
start,
end: null,
})),
});
describe("getSingularEvents", () => {
it("flattens one copy per occurrence", () => {
const a = makeEvent("a", ["2026-07-07T18:00:00Z", "2026-07-08T18:00:00Z"]);
const b = makeEvent("b", ["2026-07-09T18:00:00Z"]);
const singular = getSingularEvents([a, b]);
expect(singular).toHaveLength(3);
expect(singular.map((e) => e.occurrence.id)).toEqual(["a-0", "a-1", "b-0"]);
expect(singular[0].id).toBe("a");
expect("occurrence" in a).toBe(false);
});
});
describe("sortSingularEvents", () => {
it("sorts by occurrence start", () => {
const events = getSingularEvents([
makeEvent("late", ["2026-07-09T18:00:00Z"]),
makeEvent("early", ["2026-07-07T18:00:00Z"]),
makeEvent("mid", ["2026-07-08T18:00:00Z"]),
]);
expect(sortSingularEvents(events).map((e) => e.id)).toEqual([
"early",
"mid",
"late",
]);
});
});
describe("organizeEventsInCalendar", () => {
it("nests by yearMonth/week/day and seeds empty days", () => {
const events = getSingularEvents([
makeEvent("a", ["2026-07-07T18:00:00Z"]),
]);
const calendar = organizeEventsInCalendar(events);
expect(Object.keys(calendar)).toEqual(["2026-07"]);
const weeks = Object.values(calendar["2026-07"]);
expect(weeks).toHaveLength(1);
const week = weeks[0];
// the whole week (man 6. søn 12. juli) is pre-seeded
expect(Object.keys(week)).toHaveLength(7);
expect(week["2026-07-07"].map((e) => e.id)).toEqual(["a"]);
expect(week["2026-07-06"]).toEqual([]);
expect(week["2026-07-12"]).toEqual([]);
});
});
describe("organizeEventsByDate", () => {
it("groups by Oslo date, sorted within the day", () => {
const events = getSingularEvents([
makeEvent("kveld", ["2026-07-07T20:00:00Z"]),
makeEvent("torsdag", ["2026-07-09T18:00:00Z"]),
makeEvent("ettermiddag", ["2026-07-07T16:00:00Z"]),
]);
const byDate = organizeEventsByDate(events);
expect(Object.keys(byDate)).toEqual(["2026-07-07", "2026-07-09"]);
expect(byDate["2026-07-07"].map((e) => e.id)).toEqual([
"ettermiddag",
"kveld",
]);
});
});
describe("getFutureOccurrences", () => {
it("drops past days and sorts ascending", () => {
const event = makeEvent("a", [
"2026-07-14T18:00:00Z",
"2026-07-01T18:00:00Z",
"2026-07-07T18:00:00Z",
]);
expect(getFutureOccurrences(event).map((o) => o.start)).toEqual([
"2026-07-07T18:00:00Z",
"2026-07-14T18:00:00Z",
]);
});
});
describe("getEventPig", () => {
const pigEvent = (pig: unknown, categoryPigs: string[] = []) =>
({
pig,
categories: categoryPigs.map((p) => ({ pig: p })),
}) as unknown as EventFragment;
it("returns an explicit valid pig", () => {
expect(getEventPig(pigEvent("dance"))).toBe("dance");
});
it("returns null for empty, missing or unknown pig", () => {
expect(getEventPig(pigEvent(""))).toBeNull();
expect(getEventPig(pigEvent(null))).toBeNull();
expect(getEventPig(pigEvent("notapig"))).toBeNull();
});
it("picks a valid category pig for automatic", () => {
expect(getEventPig(pigEvent("automatic", ["music", "bogus"]))).toBe(
"music"
);
expect(["music", "drink"]).toContain(
getEventPig(pigEvent("automatic", ["music", "drink"]))
);
expect(getEventPig(pigEvent("automatic"))).toBeNull();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { internalHref } from "./links.ts";
describe("internalHref", () => {
it("passes through root-relative paths", () => {
expect(internalHref("/arrangementer")).toBe("/arrangementer");
});
it("strips the neuf.no origin, keeping query and hash", () => {
expect(internalHref("https://neuf.no/arrangementer?side=2#program")).toBe(
"/arrangementer?side=2#program"
);
expect(internalHref("https://neuf.no")).toBe("/");
});
it("returns null for external URLs", () => {
expect(internalHref("https://example.com/arrangementer")).toBeNull();
expect(internalHref("https://neuf.no.evil.com/x")).toBeNull();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// sever the server-only @/app/client import chain; the network functions are not under test
vi.mock("@/app/client", () => ({ getClient: vi.fn() }));
import type {
OpeningHoursRangeBlockFragment,
OpeningHoursSetFragment,
} from "@/gql/graphql";
import {
getOpeningHoursForFunction,
getPrettyOpeningHoursForFunction,
getTodaysOpeningHoursForFunction,
groupOpeningHours,
} from "./openinghours.ts";
const range = (
timeFrom: string | null,
timeTo: string | null,
custom: string | null = null
) => ({ timeFrom, timeTo, custom });
const week = {
__typename: "OpeningHoursWeekBlock",
monday: range("16:00:00", "23:00:00"),
tuesday: range("16:00:00", "23:00:00"),
wednesday: range("16:00:00", "23:00:00"),
thursday: range("16:00:00", "23:00:00"),
friday: range("15:00:00", "01:00:00"),
saturday: null,
sunday: range(null, null, "Kun ved arrangement"),
};
const openingHours = {
name: "Vanlige åpningstider",
effectiveFrom: "2026-01-01",
effectiveTo: null,
announcement: null,
items: [{ id: "1", function: "bar", week: [week] }],
} as unknown as OpeningHoursSetFragment;
const perDay = (days: Record<string, ReturnType<typeof range> | null>) =>
days as unknown as Record<string, OpeningHoursRangeBlockFragment>;
describe("groupOpeningHours", () => {
it("collapses adjacent days with identical hours", () => {
const grouped = groupOpeningHours(
perDay({
monday: range("16:00:00", "23:00:00"),
tuesday: range("16:00:00", "23:00:00"),
wednesday: range("12:00:00", "20:00:00"),
})
);
expect(grouped).toEqual([
{
days: ["monday", "tuesday"],
timeFrom: "16:00:00",
timeTo: "23:00:00",
custom: null,
},
{
days: ["wednesday"],
timeFrom: "12:00:00",
timeTo: "20:00:00",
custom: null,
},
]);
});
it("treats a null day as closed instead of crashing", () => {
const grouped = groupOpeningHours(
perDay({
monday: range("16:00:00", "23:00:00"),
tuesday: null,
})
);
expect(grouped[1]).toEqual({
days: ["tuesday"],
timeFrom: null,
timeTo: null,
custom: null,
});
});
it("skips days missing from the record", () => {
const grouped = groupOpeningHours(
perDay({
monday: range("16:00:00", "23:00:00"),
wednesday: range("12:00:00", "20:00:00"),
})
);
expect(grouped.map((g) => g.days)).toEqual([["monday"], ["wednesday"]]);
});
});
describe("getOpeningHoursForFunction", () => {
it("returns the week for a known function", () => {
expect(getOpeningHoursForFunction(openingHours, "bar")).toEqual(week);
});
it("returns undefined for unknown or malformed items", () => {
expect(getOpeningHoursForFunction(openingHours, "kafé")).toBeUndefined();
const malformed = {
...openingHours,
items: [{ id: "1", function: "bar", week: [] }],
} as unknown as OpeningHoursSetFragment;
expect(getOpeningHoursForFunction(malformed, "bar")).toBeUndefined();
});
});
describe("getPrettyOpeningHoursForFunction", () => {
it("formats grouped Norwegian day ranges", () => {
expect(getPrettyOpeningHoursForFunction(openingHours, "bar")).toEqual([
{ range: "mandag—torsdag", time: "16:00—23:00" },
{ range: "fredag", time: "15:00—01:00" },
{ range: "lørdag" },
{ range: "søndag", custom: "Kun ved arrangement" },
]);
});
it("returns an empty list for unknown functions", () => {
expect(getPrettyOpeningHoursForFunction(openingHours, "kafé")).toEqual([]);
});
});
describe("getTodaysOpeningHoursForFunction", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns today's hours", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z")); // tirsdag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
"16:00—23:00"
);
});
it("returns Stengt for a null day", () => {
vi.setSystemTime(new Date("2026-07-11T10:00:00Z")); // lørdag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
"Stengt"
);
});
it("returns the custom text when set", () => {
vi.setSystemTime(new Date("2026-07-12T10:00:00Z")); // søndag
expect(getTodaysOpeningHoursForFunction(openingHours, "bar")).toBe(
"Kun ved arrangement"
);
});
it("returns ? for unknown functions", () => {
vi.setSystemTime(new Date("2026-07-07T10:00:00Z"));
expect(getTodaysOpeningHoursForFunction(openingHours, "kafé")).toBe("?");
});
});
+5 -5
View File
@@ -119,9 +119,9 @@ export function groupOpeningHours(
) { ) {
grouped.push({ grouped.push({
days: [day], days: [day],
timeFrom: hours.timeFrom ?? null, timeFrom: hours?.timeFrom ?? null,
timeTo: hours.timeTo ?? null, timeTo: hours?.timeTo ?? null,
custom: hours.custom ?? null, custom: hours?.custom ?? null,
}); });
} else { } else {
grouped[grouped.length - 1].days.push(day); grouped[grouped.length - 1].days.push(day);
@@ -216,10 +216,10 @@ export function getTodaysOpeningHoursForFunction(
const weekdayIndex = getISODay(startOfToday()) - 1; const weekdayIndex = getISODay(startOfToday()) - 1;
const weekday = WEEKDAYS[weekdayIndex]; const weekday = WEEKDAYS[weekdayIndex];
const hours = week[weekday]; const hours = week[weekday];
if (hours.timeFrom && hours.timeTo) { if (hours?.timeFrom && hours?.timeTo) {
return `${hours.timeFrom.slice(0, 5)}${hours.timeTo.slice(0, 5)}`; return `${hours.timeFrom.slice(0, 5)}${hours.timeTo.slice(0, 5)}`;
} }
if (hours.custom && hours.custom.length) { if (hours?.custom?.length) {
return hours.custom; return hours.custom;
} }
return "Stengt"; return "Stengt";
+18 -17
View File
@@ -1,24 +1,25 @@
import assert from "node:assert/strict"; import { describe, expect, it } from "vitest";
import { test } from "node:test";
import { secondsUntilOsloMidnight } from "./revalidation.ts"; import { secondsUntilOsloMidnight } from "./revalidation.ts";
test("normal day: 12:00 CEST is 12h from midnight", () => { describe("secondsUntilOsloMidnight", () => {
const now = new Date("2026-07-07T10:00:00Z"); it("is 12h from midnight at 12:00 CEST on a normal day", () => {
assert.equal(secondsUntilOsloMidnight(now), 12 * 3600); const now = new Date("2026-07-07T10:00:00Z");
}); expect(secondsUntilOsloMidnight(now)).toBe(12 * 3600);
});
test("clamps to 60s just before midnight", () => { it("clamps to 60s just before midnight", () => {
const now = new Date("2026-07-07T21:59:30Z"); const now = new Date("2026-07-07T21:59:30Z");
assert.equal(secondsUntilOsloMidnight(now), 60); expect(secondsUntilOsloMidnight(now)).toBe(60);
}); });
test("DST fall-back day is 25h (2026-10-25)", () => { it("handles the 25h DST fall-back day (2026-10-25)", () => {
const now = new Date("2026-10-24T22:00:00Z"); const now = new Date("2026-10-24T22:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 25 * 3600); expect(secondsUntilOsloMidnight(now)).toBe(25 * 3600);
}); });
test("DST spring-forward day is 23h (2026-03-29)", () => { it("handles the 23h DST spring-forward day (2026-03-29)", () => {
const now = new Date("2026-03-28T23:00:00Z"); const now = new Date("2026-03-28T23:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 23 * 3600); expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
});
}); });
+63
View File
@@ -0,0 +1,63 @@
import type { ResolvingMetadata } from "next";
import { describe, expect, it } from "vitest";
import { getSeoDescription, getSeoMetadata } from "./seo.ts";
const parent = (openGraph?: object) =>
Promise.resolve({ openGraph }) as unknown as ResolvingMetadata;
describe("getSeoDescription", () => {
it("prefers searchDescription", () => {
expect(getSeoDescription("Beskrivelse", "<p>Utdrag</p>", "Ingress")).toBe(
"Beskrivelse"
);
});
it("falls back to stripped excerpt, then lead", () => {
expect(getSeoDescription(null, "<p>Utdrag</p>", "Ingress")).toBe("Utdrag");
expect(getSeoDescription("", "<p> </p>", " <b>Ingress</b> ")).toBe(
"Ingress"
);
});
it("returns undefined when everything is empty", () => {
expect(getSeoDescription(null, null, null)).toBeUndefined();
expect(getSeoDescription("", "", "")).toBeUndefined();
});
});
describe("getSeoMetadata", () => {
it("merges parent openGraph and collects images", async () => {
const page = {
seoTitle: "SEO-tittel",
title: "Tittel",
searchDescription: "Beskrivelse",
featuredImage: { url: "https://cms.neuf.no/img.jpg" },
logo: { url: "https://cms.neuf.no/logo.png" },
};
const metadata = await getSeoMetadata(page, parent({ siteName: "Neuf" }));
expect(metadata).toEqual({
title: "SEO-tittel",
description: "Beskrivelse",
openGraph: {
siteName: "Neuf",
title: "SEO-tittel",
description: "Beskrivelse",
images: [
"https://cms.neuf.no/img.jpg",
"https://cms.neuf.no/logo.png",
],
},
});
});
it("falls back to title and tolerates missing parent openGraph", async () => {
const metadata = await getSeoMetadata({ title: "Bare tittel" }, parent());
expect(metadata.title).toBe("Bare tittel");
expect(metadata.description).toBeUndefined();
expect(metadata.openGraph.images).toEqual([]);
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { ResolvingMetadata } from "next"; import type { ResolvingMetadata } from "next";
import { stripHtml } from "./common"; import { stripHtml } from "./common";
export function getSeoDescription( export function getSeoDescription(
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.{ts,tsx}"],
// date/opening-hours logic is Oslo-tz-sensitive
env: { TZ: "Europe/Oslo" },
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});