Compare commits
5
Commits
eabcd47bfd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1853e6c470
|
||
|
|
54f45820ad
|
||
|
|
031d61e1fe
|
||
|
|
952a8bca13
|
||
|
|
6370373f0d
|
@@ -0,0 +1,27 @@
|
|||||||
|
name: dnscms
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "dnscms/**"
|
||||||
|
- ".gitea/workflows/dnscms.yaml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "dnscms/**"
|
||||||
|
- ".gitea/workflows/dnscms.yaml"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: dnscms
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v7
|
||||||
|
- run: uv sync --locked
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
- run: uv run manage.py makemigrations --check --dry-run --settings=dnscms.settings.test
|
||||||
|
- run: uv run pytest
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: web
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "web/**"
|
||||||
|
- ".gitea/workflows/web.yaml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "web/**"
|
||||||
|
- ".gitea/workflows/web.yaml"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx tsc --noEmit
|
||||||
|
- run: npm test
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 99
|
line-length = 99
|
||||||
|
exclude = ["**/migrations/*.py"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["F", "E", "W", "Q", "UP", "DJ"]
|
select = ["F", "E", "W", "Q", "UP", "DJ"]
|
||||||
ignore = []
|
ignore = []
|
||||||
exclude = ["**/migrations/*.py"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "dnscms.settings.test"
|
DJANGO_SETTINGS_MODULE = "dnscms.settings.test"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+1194
-9
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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": {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -304,14 +304,14 @@ const CalendarWeek = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function maybeYear(yearMonthString: string) {
|
function srOnlyYear(yearMonthString: string) {
|
||||||
|
// formatYearMonth only shows the year for non-current years; always expose it to screen readers
|
||||||
const yearMonth = new Date(yearMonthString);
|
const yearMonth = new Date(yearMonthString);
|
||||||
const now = new Date();
|
const isCurrentYear = yearMonth.getFullYear() === new Date().getFullYear();
|
||||||
const isCurrentYear = yearMonth.getFullYear() == now.getFullYear();
|
if (!isCurrentYear) {
|
||||||
if (isCurrentYear) {
|
return null;
|
||||||
return <span className="sr-only"> {yearMonth.getFullYear()}</span>;
|
|
||||||
}
|
}
|
||||||
return ` ${yearMonth.getFullYear()}`;
|
return <span className="sr-only"> {yearMonth.getFullYear()}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
||||||
@@ -354,7 +354,7 @@ const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
|
|||||||
>
|
>
|
||||||
<h2 onClick={() => toggleYearMonth(yearMonth)}>
|
<h2 onClick={() => toggleYearMonth(yearMonth)}>
|
||||||
{formatYearMonth(yearMonth)}
|
{formatYearMonth(yearMonth)}
|
||||||
{maybeYear(yearMonth)}{" "}
|
{srOnlyYear(yearMonth)}{" "}
|
||||||
<span className={styles.eventCounter}>({eventCount})</span>
|
<span className={styles.eventCounter}>({eventCount})</span>
|
||||||
</h2>
|
</h2>
|
||||||
{eventCount === 0 && (
|
{eventCount === 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) {
|
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");
|
||||||
|
|||||||
@@ -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({
|
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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
import { stripHtml } from "./common";
|
||||||
|
|
||||||
export function getSeoDescription(
|
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