From 952a8bca13cbca7d63134aff94ee0708a023e73b Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Tue, 4 Aug 2026 03:32:45 +0200 Subject: [PATCH] web: add tests for lib/ + revalidation api --- web/src/app/api/revalidate/route.test.ts | 50 +++++++ web/src/lib/common.test.ts | 76 +++++++++++ web/src/lib/date.test.ts | 146 ++++++++++++++++++++ web/src/lib/date.ts | 2 +- web/src/lib/event.test.ts | 141 +++++++++++++++++++ web/src/lib/links.test.ts | 21 +++ web/src/lib/openinghours.test.ts | 164 +++++++++++++++++++++++ web/src/lib/openinghours.ts | 10 +- web/src/lib/revalidation.test.ts | 34 ++--- web/src/lib/seo.test.ts | 63 +++++++++ web/src/lib/seo.ts | 2 +- web/vitest.config.mts | 2 + 12 files changed, 688 insertions(+), 23 deletions(-) create mode 100644 web/src/app/api/revalidate/route.test.ts create mode 100644 web/src/lib/common.test.ts create mode 100644 web/src/lib/date.test.ts create mode 100644 web/src/lib/event.test.ts create mode 100644 web/src/lib/links.test.ts create mode 100644 web/src/lib/openinghours.test.ts create mode 100644 web/src/lib/seo.test.ts diff --git a/web/src/app/api/revalidate/route.test.ts b/web/src/app/api/revalidate/route.test.ts new file mode 100644 index 0000000..7b325ee --- /dev/null +++ b/web/src/app/api/revalidate/route.test.ts @@ -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 = {}) => + 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 }); + }); +}); diff --git a/web/src/lib/common.test.ts b/web/src/lib/common.test.ts new file mode 100644 index 0000000..89a4a94 --- /dev/null +++ b/web/src/lib/common.test.ts @@ -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("

Hei du

")).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"); + }); +}); diff --git a/web/src/lib/date.test.ts b/web/src/lib/date.test.ts new file mode 100644 index 0000000..d1ee4a5 --- /dev/null +++ b/web/src/lib/date.test.ts @@ -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" + ); + }); +}); diff --git a/web/src/lib/date.ts b/web/src/lib/date.ts index b227c04..0f79093 100644 --- a/web/src/lib/date.ts +++ b/web/src/lib/date.ts @@ -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"); diff --git a/web/src/lib/event.test.ts b/web/src/lib/event.test.ts new file mode 100644 index 0000000..df21ee1 --- /dev/null +++ b/web/src/lib/event.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/links.test.ts b/web/src/lib/links.test.ts new file mode 100644 index 0000000..de78035 --- /dev/null +++ b/web/src/lib/links.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/openinghours.test.ts b/web/src/lib/openinghours.test.ts new file mode 100644 index 0000000..9ed6320 --- /dev/null +++ b/web/src/lib/openinghours.test.ts @@ -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 | null>) => + days as unknown as Record; + +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("?"); + }); +}); diff --git a/web/src/lib/openinghours.ts b/web/src/lib/openinghours.ts index 3651f35..fcfe67d 100644 --- a/web/src/lib/openinghours.ts +++ b/web/src/lib/openinghours.ts @@ -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"; diff --git a/web/src/lib/revalidation.test.ts b/web/src/lib/revalidation.test.ts index b3275cf..d45ad53 100644 --- a/web/src/lib/revalidation.test.ts +++ b/web/src/lib/revalidation.test.ts @@ -1,23 +1,25 @@ -import { expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { secondsUntilOsloMidnight } from "./revalidation.ts"; -test("normal day: 12:00 CEST is 12h from midnight", () => { - const now = new Date("2026-07-07T10:00:00Z"); - expect(secondsUntilOsloMidnight(now)).toBe(12 * 3600); -}); +describe("secondsUntilOsloMidnight", () => { + it("is 12h from midnight at 12:00 CEST on a normal day", () => { + const now = new Date("2026-07-07T10:00:00Z"); + expect(secondsUntilOsloMidnight(now)).toBe(12 * 3600); + }); -test("clamps to 60s just before midnight", () => { - const now = new Date("2026-07-07T21:59:30Z"); - expect(secondsUntilOsloMidnight(now)).toBe(60); -}); + it("clamps to 60s just before midnight", () => { + const now = new Date("2026-07-07T21:59:30Z"); + expect(secondsUntilOsloMidnight(now)).toBe(60); + }); -test("DST fall-back day is 25h (2026-10-25)", () => { - const now = new Date("2026-10-24T22:00:00Z"); - expect(secondsUntilOsloMidnight(now)).toBe(25 * 3600); -}); + it("handles the 25h DST fall-back day (2026-10-25)", () => { + const now = new Date("2026-10-24T22:00:00Z"); + expect(secondsUntilOsloMidnight(now)).toBe(25 * 3600); + }); -test("DST spring-forward day is 23h (2026-03-29)", () => { - const now = new Date("2026-03-28T23:00:00Z"); - expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600); + it("handles the 23h DST spring-forward day (2026-03-29)", () => { + const now = new Date("2026-03-28T23:00:00Z"); + expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600); + }); }); diff --git a/web/src/lib/seo.test.ts b/web/src/lib/seo.test.ts new file mode 100644 index 0000000..70d3fad --- /dev/null +++ b/web/src/lib/seo.test.ts @@ -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", "

Utdrag

", "Ingress")).toBe( + "Beskrivelse" + ); + }); + + it("falls back to stripped excerpt, then lead", () => { + expect(getSeoDescription(null, "

Utdrag

", "Ingress")).toBe("Utdrag"); + expect(getSeoDescription("", "

", " Ingress ")).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([]); + }); +}); diff --git a/web/src/lib/seo.ts b/web/src/lib/seo.ts index b0c0d79..4d15ae9 100644 --- a/web/src/lib/seo.ts +++ b/web/src/lib/seo.ts @@ -1,4 +1,4 @@ -import { ResolvingMetadata } from "next"; +import type { ResolvingMetadata } from "next"; import { stripHtml } from "./common"; export function getSeoDescription( diff --git a/web/vitest.config.mts b/web/vitest.config.mts index f6b319e..0218288 100644 --- a/web/vitest.config.mts +++ b/web/vitest.config.mts @@ -4,6 +4,8 @@ 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: {