Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952a8bca13
|
||
|
|
6370373f0d
|
@@ -24,11 +24,12 @@ npm install
|
||||
npm run dev # http://localhost:3000
|
||||
npm run codegen # regenerate GraphQL types (needs the backend running)
|
||||
npm run build
|
||||
npm test # vitest (npm run test:watch for watch mode)
|
||||
```
|
||||
|
||||
## 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
|
||||
prek install # registers the git hook
|
||||
|
||||
@@ -27,3 +27,14 @@ exclude = '/migrations/'
|
||||
id = "ruff-format"
|
||||
files = '^dnscms/.*\.py$'
|
||||
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
|
||||
|
||||
Generated
+1194
-9
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -7,7 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "node --test src/lib/*.test.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"codegen": "graphql-codegen",
|
||||
"perf:build": "next build",
|
||||
"perf:serve": "next start -p 3100",
|
||||
@@ -45,6 +46,7 @@
|
||||
"eslint-config-next": "16.2.10",
|
||||
"lighthouse": "^13.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -27,7 +27,7 @@ export function formatDate(date: Date | string | number, formatStr: string) {
|
||||
export function formatYearMonth(yearMonth: string) {
|
||||
// full name of month if year is current year, otherwise name of month + year
|
||||
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 yyyy");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("?");
|
||||
});
|
||||
});
|
||||
@@ -119,9 +119,9 @@ export function groupOpeningHours(
|
||||
) {
|
||||
grouped.push({
|
||||
days: [day],
|
||||
timeFrom: hours.timeFrom ?? null,
|
||||
timeTo: hours.timeTo ?? null,
|
||||
custom: hours.custom ?? null,
|
||||
timeFrom: hours?.timeFrom ?? null,
|
||||
timeTo: hours?.timeTo ?? null,
|
||||
custom: hours?.custom ?? null,
|
||||
});
|
||||
} else {
|
||||
grouped[grouped.length - 1].days.push(day);
|
||||
@@ -216,10 +216,10 @@ export function getTodaysOpeningHoursForFunction(
|
||||
const weekdayIndex = getISODay(startOfToday()) - 1;
|
||||
const weekday = WEEKDAYS[weekdayIndex];
|
||||
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)}`;
|
||||
}
|
||||
if (hours.custom && hours.custom.length) {
|
||||
if (hours?.custom?.length) {
|
||||
return hours.custom;
|
||||
}
|
||||
return "Stengt";
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { secondsUntilOsloMidnight } from "./revalidation.ts";
|
||||
|
||||
test("normal day: 12:00 CEST is 12h from midnight", () => {
|
||||
describe("secondsUntilOsloMidnight", () => {
|
||||
it("is 12h from midnight at 12:00 CEST on a normal day", () => {
|
||||
const now = new Date("2026-07-07T10:00:00Z");
|
||||
assert.equal(secondsUntilOsloMidnight(now), 12 * 3600);
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
assert.equal(secondsUntilOsloMidnight(now), 23 * 3600);
|
||||
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { ResolvingMetadata } from "next";
|
||||
import type { ResolvingMetadata } from "next";
|
||||
import { stripHtml } from "./common";
|
||||
|
||||
export function getSeoDescription(
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user