From 1f458e617388d7e70b1bba921ca2763c50abfad0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 17:48:12 +0000
Subject: [PATCH 001/132] chore: ignore agent worktrees in lint and reopen work
(remove DONE marker)
---
.claude/DONE | 1 -
.prettierignore | 1 +
eslint.config.js | 2 ++
3 files changed, 3 insertions(+), 1 deletion(-)
delete mode 100644 .claude/DONE
diff --git a/.claude/DONE b/.claude/DONE
deleted file mode 100644
index c1f56f9..0000000
--- a/.claude/DONE
+++ /dev/null
@@ -1 +0,0 @@
-MVP ferdig og verifisert 2026-09-30. Se docs/status.md.
diff --git a/.prettierignore b/.prettierignore
index f4e4841..8e933e6 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,3 +5,4 @@ playwright-report
test-results
package-lock.json
data
+.claude
diff --git a/eslint.config.js b/eslint.config.js
index ef27e03..87eb280 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -15,6 +15,8 @@ export default tseslint.config(
'playwright-report/**',
'test-results/**',
'data/**',
+ // Agentenes midlertidige git-worktrees er kopier av repoet og skal ikke lintes to ganger.
+ '.claude/**',
],
},
js.configs.recommended,
From bbbcb0bc82b74e96bd46c4a5034bf88d0d490d2a Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 17:51:06 +0000
Subject: [PATCH 002/132] fix(backend): validate title ids as tt + 7-10 digits
(#15)
Also corrects the relevance formula in docs/beslutninger.md.
---
backend/src/validation.test.ts | 22 ++++++++++++++++++++++
backend/src/validation.ts | 10 ++++++++--
docs/beslutninger.md | 9 +++++++--
3 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts
index fb15d02..8128e00 100644
--- a/backend/src/validation.test.ts
+++ b/backend/src/validation.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
+ isPlausibleTitleId,
parseUserId,
validateFilters,
validateFirst,
@@ -76,3 +77,24 @@ describe('validateReview', () => {
expect(() => validateReview({ ...ok, text: 'x'.repeat(2001) })).toThrow();
});
});
+
+describe('isPlausibleTitleId', () => {
+ it.each(['tt0111161', 'tt1234567', 'tt12345678', 'tt1234567890'])('godtar %s', (id) =>
+ expect(isPlausibleTitleId(id)).toBe(true),
+ );
+ it.each([
+ '',
+ 'tt',
+ 'tt123456',
+ 'tt12345678901',
+ 'TT0111161',
+ 'nm0000001',
+ '0111161',
+ 'tt011116a',
+ 'tt0111161\n',
+ ' tt0111161',
+ 'tt0111161\0',
+ "tt0111161' OR 1=1",
+ 'tt' + '1'.repeat(1000),
+ ])('avviser %j', (id) => expect(isPlausibleTitleId(id)).toBe(false));
+});
diff --git a/backend/src/validation.ts b/backend/src/validation.ts
index a7486cc..fca89ac 100644
--- a/backend/src/validation.ts
+++ b/backend/src/validation.ts
@@ -123,7 +123,13 @@ export function validateReview(input: ReviewInput): ReviewInput {
return { titleId: input.titleId, author, rating: input.rating, text };
}
-/** ID-er er tconst («tt0111161»); alt annet kan vi avvise uten databasekall. */
+const TITLE_ID_RE = /^tt\d{7,10}$/;
+
+/**
+ * ID-er er IMDb tconst: «tt» + 7–10 siffer (dagens ID-er har 7–8; 10 gir slingringsmonn når IMDb
+ * går over til flere siffer). Alt annet avvises uten databasekall, og regelen holder også
+ * NUL-byte og vilkårlig lange strenger unna Postgres.
+ */
export function isPlausibleTitleId(id: string): boolean {
- return id.length > 0 && id.length <= 30 && !id.includes('\0');
+ return TITLE_ID_RE.test(id);
}
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
index 5c7973d..2644ba9 100644
--- a/docs/beslutninger.md
+++ b/docs/beslutninger.md
@@ -35,8 +35,13 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
Cursoren inneholder en signatur (sortering/retning/om det finnes søketekst) og avvises
(`BAD_USER_INPUT`) hvis den brukes med en annen sortering. Tidsstempler i cursorer går som tekst
(mikrosekund-presisjon; JS `Date` har bare millisekunder).
-- **Relevans = `GREATEST(similarity(primær), similarity(original))`,** tiebreak `num_votes`, så `id`.
- Uten søketekst betyr «relevans» flest stemmer først.
+- **Relevans = `0,6 * word_similarity + 0,2 * similarity + 0,2 * popularitet`,** tiebreak `num_votes`,
+ så `id`. Begge likhetsmålene tas som `GREATEST` over primær- og originaltittel. Ren likhet rangerte
+ obskure titler med identisk navn («Dark Knight», 1 500 stemmer) over klassikeren brukeren mener
+ («The Dark Knight»). `word_similarity` gir full score når søkeordene står i tittelen, `similarity`
+ belønner eksakte treff, og popularitet (`log10(stemmer) / 7`, mettet ved 10 mill.) skiller resten.
+ Uten søketekst betyr «relevans» flest stemmer først. Formelen står i `sortKeys` i
+ `backend/src/search.ts`.
- **`totalCount` er lazy** (beregnes bare hvis feltet etterspørres) og kjøres som egen `count(*)`.
`userRating`/`reviewCount`/`inMyList` hentes med en liten per-request batch-loader (`loaders.ts`),
så en side med 20 titler koster 1 spørring per felttype i stedet for 20.
From 5739e16e4f16d28780d89af667758259c93b8e01 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 17:52:31 +0000
Subject: [PATCH 003/132] feat(backend): limit root fields and total fields per
operation (#15)
A flat document with hundreds of aliased search or posterUrl fields passed depthLimit.
---
backend/src/app.ts | 6 ++
backend/src/complexityLimit.test.ts | 67 ++++++++++++++++++
backend/src/complexityLimit.ts | 105 ++++++++++++++++++++++++++++
backend/test/server.test.ts | 52 ++++++++++++++
4 files changed, 230 insertions(+)
create mode 100644 backend/src/complexityLimit.test.ts
create mode 100644 backend/src/complexityLimit.ts
diff --git a/backend/src/app.ts b/backend/src/app.ts
index c4facf2..336c35a 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -4,10 +4,15 @@ import { ArtworkService } from './artwork.js';
import { config } from './config.js';
import { createContext, type Context } from './context.js';
import type { Pool } from './db.js';
+import { complexityLimit } from './complexityLimit.js';
import { depthLimit } from './depthLimit.js';
import { schema } from './schema.js';
export const MAX_QUERY_DEPTH = 6;
+// Frontendens største spørring (Title-detaljene) har ca. 45 felt og ett rotfelt. Grensene gir rundt
+// tre ganger slingringsmonn, men stopper hundrevis av aliasede søk eller TMDB-oppslag i ett dokument.
+export const MAX_ROOT_FIELDS = 8;
+export const MAX_FIELDS = 150;
/**
* Egen regel i stedet for graphqls NoSchemaIntrospectionCustomRule: den bruker instanceof-sjekker
@@ -56,6 +61,7 @@ export function createApp({
const limits: Plugin = {
onValidate({ addValidationRule }) {
addValidationRule(depthLimit(MAX_QUERY_DEPTH));
+ addValidationRule(complexityLimit({ maxRootFields: MAX_ROOT_FIELDS, maxFields: MAX_FIELDS }));
if (isProd) addValidationRule(noIntrospection);
},
};
diff --git a/backend/src/complexityLimit.test.ts b/backend/src/complexityLimit.test.ts
new file mode 100644
index 0000000..8bce5f6
--- /dev/null
+++ b/backend/src/complexityLimit.test.ts
@@ -0,0 +1,67 @@
+import { buildSchema, parse, validate } from 'graphql';
+import { describe, expect, it } from 'vitest';
+import { complexityLimit } from './complexityLimit.js';
+
+const schema = buildSchema(`
+ type Node { id: ID, name: String, child: Node }
+ type Query { root: Node }
+`);
+const check = (q: string, maxRootFields = 3, maxFields = 10) =>
+ validate(schema, parse(q), [complexityLimit({ maxRootFields, maxFields })]);
+
+const aliases = (n: number) => Array.from({ length: n }, (_, i) => `a${i}: root { id }`).join(' ');
+
+describe('complexityLimit', () => {
+ it('godtar rotfelt akkurat på grensen og avviser ett over', () => {
+ expect(check(`{ ${aliases(3)} }`, 3, 100)).toHaveLength(0);
+ const errors = check(`{ ${aliases(4)} }`, 3, 100);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT');
+ expect(errors[0]?.message).toContain('rotfelt');
+ });
+
+ it('teller aliaser av samme felt hver for seg', () => {
+ expect(check(`{ ${aliases(500)} }`, 8, 1000)).toHaveLength(1);
+ });
+
+ it('teller felt totalt: grensen er inklusiv', () => {
+ // root + id + name + child + id + name = 6 felt.
+ const q = '{ root { id name child { id name } } }';
+ expect(check(q, 3, 6)).toHaveLength(0);
+ const errors = check(q, 3, 5);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT');
+ expect(errors[0]?.message).toContain('6 felt');
+ });
+
+ it('teller felt i fragmenter og inline-fragmenter, og hvert bruk av fragmentet', () => {
+ const q = `
+ query { root { ...F } b: root { ...F } c: root { ... on Node { id name } } }
+ fragment F on Node { id name }`;
+ // 3 rotfelt + 2 + 2 + 2 = 9
+ expect(check(q, 3, 9)).toHaveLength(0);
+ expect(check(q, 3, 8)).toHaveLength(1);
+ });
+
+ it('hopper over introspeksjon', () => {
+ expect(check('{ __schema { types { fields { type { ofType { name } } } } } }')).toHaveLength(0);
+ });
+
+ it('tåler eksponentielt voksende og sirkulære fragmenter uten å henge', () => {
+ // Hvert nivå bruker det neste ti ganger: 10^8 noder uten memoisering.
+ const levels = 8;
+ let doc = '{ root { ...F0 } }';
+ for (let i = 0; i < levels; i++) {
+ const uses = Array.from({ length: 10 }, () => `...F${i + 1}`).join(' ');
+ doc += ` fragment F${i} on Node { ${uses} }`;
+ }
+ doc += ` fragment F${levels} on Node { id }`;
+ const started = Date.now();
+ expect(check(doc, 3, 1000)).toHaveLength(1);
+ expect(Date.now() - started).toBeLessThan(500);
+ // Sirkel A -> B -> A: selve avvisningen gjøres av NoFragmentCycles; vi må bare ikke løpe i ring.
+ expect(() =>
+ check('{ root { ...A } } fragment A on Node { child { ...B } } fragment B on Node { ...A }'),
+ ).not.toThrow();
+ });
+});
diff --git a/backend/src/complexityLimit.ts b/backend/src/complexityLimit.ts
new file mode 100644
index 0000000..3b625f1
--- /dev/null
+++ b/backend/src/complexityLimit.ts
@@ -0,0 +1,105 @@
+import {
+ GraphQLError,
+ Kind,
+ type FragmentDefinitionNode,
+ type SelectionSetNode,
+ type ValidationRule,
+} from 'graphql';
+
+export interface ComplexityLimits {
+ /** Maks antall rotfelt (aliaser teller hver for seg: `a: search`, `b: search` er to). */
+ maxRootFields: number;
+ /** Maks antall felt totalt i én operasjon, fragmenter ekspandert og aliaser talt hver for seg. */
+ maxFields: number;
+}
+
+/**
+ * Dybdegrensen stopper bare nøsting. Én flat spørring med hundrevis av aliasede `search(...)`
+ * (hver gir en egen SQL-spørring) eller `posterUrl`/`overview` (TMDB-oppslag) er grunn, men dyr.
+ * Denne regelen teller derfor felt: rotfelt for seg (det er dem som starter databasekall), og alle
+ * felt totalt (det som bestemmer størrelsen på svaret og antall resolver-kall).
+ *
+ * Grensene er satt godt over de største spørringene frontend sender (se operations.ts: Title-
+ * detaljene er størst), slik at vanlig bruk aldri kommer i nærheten.
+ */
+export function complexityLimit({ maxRootFields, maxFields }: ComplexityLimits): ValidationRule {
+ return (context) => ({
+ OperationDefinition(operation) {
+ const fragments = new Map();
+ for (const def of context.getDocument().definitions) {
+ if (def.kind === Kind.FRAGMENT_DEFINITION) fragments.set(def.name.value, def);
+ }
+
+ // Memoisert per fragment: uten dette kan et dokument der fragment A bruker B ti ganger, B
+ // bruker C ti ganger osv. få oss til å traversere eksponentielt mange noder bare for å telle.
+ const memo = new Map();
+ const countSet = (set: SelectionSetNode, visiting: Set): number => {
+ let n = 0;
+ for (const sel of set.selections) {
+ if (sel.kind === Kind.FIELD) {
+ // Introspeksjon har egen regel (slått av i produksjon) og er stor av natur.
+ if (sel.name.value === '__schema' || sel.name.value === '__type') continue;
+ n += 1 + (sel.selectionSet ? countSet(sel.selectionSet, visiting) : 0);
+ } else if (sel.kind === Kind.INLINE_FRAGMENT) {
+ n += countSet(sel.selectionSet, visiting);
+ } else {
+ const name = sel.name.value;
+ const frag = fragments.get(name);
+ // Sirkulære fragmenter fanges av NoFragmentCycles; her må vi bare ikke løpe i ring.
+ if (!frag || visiting.has(name)) continue;
+ let c = memo.get(name);
+ if (c === undefined) {
+ visiting.add(name);
+ c = countSet(frag.selectionSet, visiting);
+ visiting.delete(name);
+ memo.set(name, c);
+ }
+ n += c;
+ }
+ }
+ return n;
+ };
+
+ const rootFields = (set: SelectionSetNode, visiting: Set): number => {
+ let n = 0;
+ for (const sel of set.selections) {
+ if (sel.kind === Kind.FIELD) {
+ if (sel.name.value !== '__schema' && sel.name.value !== '__type') n++;
+ } else if (sel.kind === Kind.INLINE_FRAGMENT) {
+ n += rootFields(sel.selectionSet, visiting);
+ } else {
+ const name = sel.name.value;
+ const frag = fragments.get(name);
+ if (!frag || visiting.has(name)) continue;
+ visiting.add(name);
+ n += rootFields(frag.selectionSet, visiting);
+ visiting.delete(name);
+ }
+ }
+ return n;
+ };
+
+ const roots = rootFields(operation.selectionSet, new Set());
+ if (roots > maxRootFields) {
+ context.reportError(
+ new GraphQLError(
+ `Spørringen har for mange rotfelt (${roots}); maks tillatt er ${maxRootFields}. ` +
+ 'Del den opp i flere forespørsler.',
+ { nodes: [operation], extensions: { code: 'BAD_USER_INPUT' } },
+ ),
+ );
+ return;
+ }
+ const total = countSet(operation.selectionSet, new Set());
+ if (total > maxFields) {
+ context.reportError(
+ new GraphQLError(
+ `Spørringen er for omfattende (${total} felt); maks tillatt er ${maxFields}. ` +
+ 'Be om færre felt eller del den opp.',
+ { nodes: [operation], extensions: { code: 'BAD_USER_INPUT' } },
+ ),
+ );
+ }
+ },
+ });
+}
diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts
index 45aa49e..18b3a81 100644
--- a/backend/test/server.test.ts
+++ b/backend/test/server.test.ts
@@ -65,6 +65,58 @@ describe('servergrenser', () => {
expect((await preflight(without)).headers.get('access-control-allow-origin')).toBeNull();
});
+ describe('kompleksitetsgrense', () => {
+ const run = async (query: string) =>
+ (await post(createApp({ pool }), query, {})).json() as Promise<{
+ data?: unknown;
+ errors?: { message: string; extensions?: { code?: string } }[];
+ }>;
+
+ // Kopier av frontendens største spørringer (frontend/src/graphql/operations.ts). Hvis disse
+ // begynner å feile, har grensene blitt for stramme for den faktiske klienten.
+ const SUMMARY = `id primaryTitle type startYear genres averageRating numVotes
+ poster185: posterUrl(width: 185) poster342: posterUrl(width: 342) stream { url }`;
+ const HERO = `overview backdrop780: backdropUrl(width: 780) backdrop1280: backdropUrl(width: 1280)`;
+ const FEATURED = `query { search(first: 20) { totalCount pageInfo { hasNextPage endCursor }
+ edges { cursor node { ${SUMMARY} ${HERO} inMyList } } } }`;
+ const TITLE = `query { title(id: "tt0000001") { ${SUMMARY} ${HERO}
+ poster500: posterUrl(width: 500) originalTitle endYear runtimeMinutes userRating reviewCount
+ inMyList stream { url archiveUrl license licenseUrl durationSeconds subtitlesUrl }
+ reviews(first: 10) { totalCount pageInfo { hasNextPage endCursor }
+ edges { cursor node { id titleId author rating text createdAt isMine } } } } }`;
+
+ it('slipper gjennom frontendens største spørringer', async () => {
+ for (const q of [FEATURED, TITLE]) {
+ const res = await run(q);
+ expect(res.errors, q.slice(0, 40)).toBeUndefined();
+ }
+ });
+
+ it('avviser hundrevis av aliasede søk i ett dokument', async () => {
+ const q = `{ ${Array.from({ length: 300 }, (_, i) => `s${i}: search(query: "a") { totalCount }`).join(' ')} }`;
+ const res = await run(q);
+ expect(res.data).toBeUndefined();
+ expect(res.errors).toHaveLength(1);
+ expect(res.errors![0]!.extensions?.code).toBe('BAD_USER_INPUT');
+ expect(res.errors![0]!.message).toMatch(/rotfelt/);
+ });
+
+ it('avviser mange aliasede TMDB-felt på én tittel', async () => {
+ const q = `{ title(id: "tt0000001") { ${Array.from({ length: 200 }, (_, i) => `p${i}: posterUrl(width: 92)`).join(' ')} } }`;
+ const res = await run(q);
+ expect(res.data).toBeUndefined();
+ expect(res.errors![0]!.extensions?.code).toBe('BAD_USER_INPUT');
+ expect(res.errors![0]!.message).toMatch(/felt/);
+ });
+
+ it('godtar akkurat grensen for rotfelt (8) og avviser 9', async () => {
+ const roots = (n: number) =>
+ `{ ${Array.from({ length: n }, (_, i) => `g${i}: genres`).join(' ')} }`;
+ expect((await run(roots(8))).errors).toBeUndefined();
+ expect((await run(roots(9))).errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ });
+ });
+
it('tåler ugyldig JSON-body og manglende query', async () => {
const yoga = createApp({ pool });
const bad = await yoga.fetch('http://localhost/graphql', {
From e4339437569ef680cda7963229711c1fb03ec3bd Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 17:55:43 +0000
Subject: [PATCH 004/132] feat(backend): add deleteReview and in-memory
mutation rate limiting (#15)
deleteReview only deletes reviews owned by the caller. Mutations are limited per user id and per client IP with token buckets.
---
backend/src/app.ts | 8 +-
backend/src/context.ts | 7 ++
backend/src/errors.ts | 6 +-
backend/src/rateLimit.ts | 117 +++++++++++++++++++++
backend/src/resolvers/index.ts | 24 ++++-
backend/src/typeDefs.ts | 7 ++
backend/test/delete-review.test.ts | 106 +++++++++++++++++++
backend/test/helpers.ts | 5 +-
backend/test/rate-limit.test.ts | 157 +++++++++++++++++++++++++++++
docs/api.md | 20 +++-
docs/beslutninger.md | 26 +++++
11 files changed, 478 insertions(+), 5 deletions(-)
create mode 100644 backend/src/rateLimit.ts
create mode 100644 backend/test/delete-review.test.ts
create mode 100644 backend/test/rate-limit.test.ts
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 336c35a..7481e4a 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -6,6 +6,7 @@ import { createContext, type Context } from './context.js';
import type { Pool } from './db.js';
import { complexityLimit } from './complexityLimit.js';
import { depthLimit } from './depthLimit.js';
+import { DEFAULT_RATE_LIMITS, MutationLimiter, type RateLimitOptions } from './rateLimit.js';
import { schema } from './schema.js';
export const MAX_QUERY_DEPTH = 6;
@@ -41,6 +42,8 @@ export interface AppOptions {
artwork?: ArtworkService;
/** Base-URL for Internet Archive i stream-URL-ene. Standard: ARCHIVE_URL. */
archiveUrl?: string;
+ /** Overstyrer grensene for mutations (se rateLimit.ts). Standard: DEFAULT_RATE_LIMITS. */
+ rateLimits?: Partial;
}
/**
@@ -53,7 +56,10 @@ export function createApp({
corsOrigin = false,
artwork,
archiveUrl,
+ rateLimits,
}: AppOptions) {
+ // Én begrenser per app: tilstanden må overleve mellom requests, i motsetning til konteksten.
+ const limiter = new MutationLimiter({ ...DEFAULT_RATE_LIMITS, ...rateLimits });
const artworkService = artwork ?? new ArtworkService({ pool, apiKey: config.tmdbApiKey });
const isProd = production ?? process.env.NODE_ENV === 'production';
@@ -71,7 +77,7 @@ export function createApp({
graphqlEndpoint: '/graphql',
plugins: [limits],
context: ({ request }) =>
- createContext(pool, request, artworkService, archiveUrl ?? config.archiveUrl),
+ createContext(pool, request, artworkService, archiveUrl ?? config.archiveUrl, limiter),
logging: process.env.NODE_ENV !== 'test',
graphiql: !isProd,
landingPage: !isProd,
diff --git a/backend/src/context.ts b/backend/src/context.ts
index a585356..756a52d 100644
--- a/backend/src/context.ts
+++ b/backend/src/context.ts
@@ -2,6 +2,7 @@ import { createArtworkLoader, type ArtworkLoader, type ArtworkService } from './
import type { Pool } from './db.js';
import { config } from './config.js';
import { batchLoader } from './loaders.js';
+import { clientIp, MutationLimiter } from './rateLimit.js';
import { createStreamLoader, type StreamLoader } from './stream.js';
import { parseUserId } from './validation.js';
@@ -14,6 +15,9 @@ export interface Context {
pool: Pool;
/** Gyldig, normalisert UUID fra x-user-id, ellers null. */
userId: string | null;
+ /** Klientens IP bak proxy, ellers null. Brukes bare til begrensning av mutations. */
+ ip: string | null;
+ limiter: MutationLimiter;
artwork: ArtworkService;
loaders: {
artwork: ArtworkLoader;
@@ -29,11 +33,14 @@ export function createContext(
request: Request,
artwork: ArtworkService,
archiveUrl: string = config.archiveUrl,
+ limiter: MutationLimiter = new MutationLimiter(),
): Context {
const userId = parseUserId(request.headers.get('x-user-id'));
return {
pool,
userId,
+ ip: clientIp(request),
+ limiter,
artwork,
loaders: {
artwork: createArtworkLoader(artwork),
diff --git a/backend/src/errors.ts b/backend/src/errors.ts
index d7ecb9d..665a0d4 100644
--- a/backend/src/errors.ts
+++ b/backend/src/errors.ts
@@ -1,6 +1,6 @@
import { GraphQLError } from 'graphql';
-export type ErrorCode = 'BAD_USER_INPUT' | 'NOT_FOUND' | 'UNAUTHENTICATED';
+export type ErrorCode = 'BAD_USER_INPUT' | 'NOT_FOUND' | 'UNAUTHENTICATED' | 'RATE_LIMITED';
function make(code: ErrorCode) {
return (message: string) => new GraphQLError(message, { extensions: { code } });
@@ -9,3 +9,7 @@ function make(code: ErrorCode) {
export const badInput = make('BAD_USER_INPUT');
export const notFound = make('NOT_FOUND');
export const unauthenticated = make('UNAUTHENTICATED');
+
+/** `retryAfterSeconds` lar klienten vise en nedtelling i stedet for å gjette. */
+export const rateLimited = (message: string, retryAfterSeconds: number) =>
+ new GraphQLError(message, { extensions: { code: 'RATE_LIMITED', retryAfterSeconds } });
diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts
new file mode 100644
index 0000000..ae7d4c9
--- /dev/null
+++ b/backend/src/rateLimit.ts
@@ -0,0 +1,117 @@
+import { rateLimited } from './errors.js';
+
+export interface RateLimitOptions {
+ /** addReview: anmeldelser per minutt per bruker (også burst-størrelsen). */
+ reviewsPerMinute: number;
+ /** toggleList og deleteReview: kall per minutt per bruker. */
+ mutationsPerMinute: number;
+ /** Per-IP-grensen er dette ganger brukergrensen (flere brukere kan dele nett, f.eks. en klasse). */
+ ipFactor: number;
+}
+
+export const DEFAULT_RATE_LIMITS: RateLimitOptions = {
+ // Romslig nok til at en person som rydder opp eller tester appen aldri merker det, men en
+ // skript-løkke stoppes etter ett minutts verdi.
+ reviewsPerMinute: 10,
+ mutationsPerMinute: 60,
+ ipFactor: 3,
+};
+
+export type MutationKind = 'review' | 'mutation';
+
+interface Bucket {
+ tokens: number;
+ updatedAt: number;
+}
+
+/** Hindrer at minnet vokser uten grense hvis noen finner opp nye UUID-er i det uendelige. */
+const MAX_KEYS = 50_000;
+
+/**
+ * Token bucket i minnet: `capacity` tokens, fylt jevnt opp til full over ett minutt. Et kall koster
+ * ett token, så en rolig bruker aldri møter grensen mens en løkke stoppes etter `capacity` kall.
+ */
+export class TokenBuckets {
+ private readonly buckets = new Map();
+
+ constructor(
+ private readonly capacity: number,
+ private readonly now: () => number = Date.now,
+ ) {}
+
+ /** Tar ett token. Returnerer 0 hvis det gikk bra, ellers sekunder til neste token er tilgjengelig. */
+ take(key: string): number {
+ const now = this.now();
+ const perMs = this.capacity / 60_000;
+ const b = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
+ b.tokens = Math.min(this.capacity, b.tokens + (now - b.updatedAt) * perMs);
+ b.updatedAt = now;
+ // Flytter nøkkelen sist i Map-rekkefølgen, slik at de eldste er først når vi må rydde.
+ this.buckets.delete(key);
+ this.buckets.set(key, b);
+ if (this.buckets.size > MAX_KEYS) this.prune(now);
+ if (b.tokens >= 1) {
+ b.tokens -= 1;
+ return 0;
+ }
+ return Math.max(1, Math.ceil((1 - b.tokens) / perMs / 1000));
+ }
+
+ private prune(now: number) {
+ // En full bøtte er identisk med en ny, så å glemme den endrer ingenting.
+ const perMs = this.capacity / 60_000;
+ for (const [key, b] of this.buckets) {
+ if (b.tokens + (now - b.updatedAt) * perMs >= this.capacity) this.buckets.delete(key);
+ }
+ // Fortsatt for mange (aktivt misbruk): kast de eldste uansett. Verste utfall er at en
+ // misbruker får en ny bøtte; minnet er viktigere enn presisjon her.
+ for (const key of this.buckets.keys()) {
+ if (this.buckets.size <= MAX_KEYS) break;
+ this.buckets.delete(key);
+ }
+ }
+}
+
+/**
+ * Enkel begrensning på mutations. Tilstanden ligger i prosessminnet, noe som er greit så lenge
+ * backend kjører som én prosess (én systemd-tjeneste på VM-en). Ved flere instanser eller restart
+ * nullstilles grensene; en delt grense ville krevd Redis eller en tabell, og det er overkill her.
+ */
+export class MutationLimiter {
+ private readonly user: Record;
+ private readonly ip: Record;
+
+ constructor(options: RateLimitOptions = DEFAULT_RATE_LIMITS, now: () => number = Date.now) {
+ const cap = { review: options.reviewsPerMinute, mutation: options.mutationsPerMinute };
+ const ipCap = (n: number) => Math.max(1, Math.round(n * options.ipFactor));
+ this.user = {
+ review: new TokenBuckets(cap.review, now),
+ mutation: new TokenBuckets(cap.mutation, now),
+ };
+ this.ip = {
+ review: new TokenBuckets(ipCap(cap.review), now),
+ mutation: new TokenBuckets(ipCap(cap.mutation), now),
+ };
+ }
+
+ /** Kaster RATE_LIMITED når brukeren (eller IP-adressen) har brukt opp kvoten for denne typen. */
+ check(kind: MutationKind, userId: string, ip: string | null): void {
+ // IP sjekkes først: en avvist forespørsel skal ikke også spise brukerens egen kvote.
+ const wait = (ip ? this.ip[kind].take(ip) : 0) || this.user[kind].take(userId);
+ if (wait > 0) {
+ throw rateLimited(`For mange forsøk. Vent ${wait} sekunder og prøv igjen.`, wait);
+ }
+ }
+}
+
+/**
+ * Klientens IP bak Apache: mod_proxy legger den til sist i X-Forwarded-For, så siste ledd er
+ * ikke noe klienten kan forfalske (tidligere ledd kan). Uten proxy (utvikling/tester) er det ingen
+ * header, og da begrenses bare per bruker.
+ */
+export function clientIp(request: Request): string | null {
+ const header = request.headers.get('x-forwarded-for');
+ if (!header) return null;
+ const last = header.split(',').at(-1)?.trim();
+ return last ? last.slice(0, 64) : null;
+}
diff --git a/backend/src/resolvers/index.ts b/backend/src/resolvers/index.ts
index 0ffa673..abb3779 100644
--- a/backend/src/resolvers/index.ts
+++ b/backend/src/resolvers/index.ts
@@ -1,5 +1,5 @@
import { BACKDROP_WIDTHS, POSTER_WIDTHS, buildImageUrl } from '../artwork.js';
-import { notFound, unauthenticated } from '../errors.js';
+import { badInput, notFound, unauthenticated } from '../errors.js';
import type { Context } from '../context.js';
import {
DEFAULT_DIRECTION,
@@ -97,6 +97,8 @@ export const resolvers = {
Mutation: {
addReview: async (_: unknown, args: { input: ReviewInput }, ctx: Context) => {
if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.');
+ // Før validering: ugyldige forsøk skal også koste, ellers kan en løkke prøve fritt.
+ ctx.limiter.check('review', ctx.userId, ctx.ip);
const input = validateReview(args.input);
if (!isPlausibleTitleId(input.titleId) || !(await getTitle(ctx.pool, input.titleId))) {
throw notFound('Fant ikke tittelen.');
@@ -112,6 +114,7 @@ export const resolvers = {
toggleList: async (_: unknown, args: { titleId: string }, ctx: Context) => {
if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.');
+ ctx.limiter.check('mutation', ctx.userId, ctx.ip);
const title = isPlausibleTitleId(args.titleId)
? await getTitle(ctx.pool, args.titleId)
: null;
@@ -132,6 +135,25 @@ export const resolvers = {
);
return { ...mapTitle(title), inMyList: rows[0]?.in_list ?? false };
},
+
+ deleteReview: async (_: unknown, args: { id: string }, ctx: Context) => {
+ if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.');
+ ctx.limiter.check('mutation', ctx.userId, ctx.ip);
+ // bigint tar maks 19 siffer; 18 er nok og gir aldri overflow i ::bigint-castet.
+ if (!/^[1-9]\d{0,17}$/.test(args.id)) throw badInput('Ugyldig id.');
+ // Eierskapet sjekkes i selve DELETE-en. «Finnes ikke» og «tilhører andre» gir samme svar,
+ // så en fremmed ikke kan bruke mutationen til å lete etter anmeldelses-id-er.
+ const { rows } = await ctx.pool.query<{ title_id: string }>(
+ 'DELETE FROM reviews WHERE id = $1::bigint AND user_id = $2 RETURNING title_id',
+ [args.id, ctx.userId],
+ );
+ const titleId = rows[0]?.title_id;
+ if (!titleId) throw notFound('Fant ikke anmeldelsen.');
+ const title = await getTitle(ctx.pool, titleId);
+ // Tittelen kan ikke mangle (reviews.title_id har fremmednøkkel), men typene krever sjekken.
+ if (!title) throw notFound('Fant ikke tittelen.');
+ return { deletedId: args.id, title: mapTitle(title) };
+ },
},
Title: {
diff --git a/backend/src/typeDefs.ts b/backend/src/typeDefs.ts
index d634d9b..35eafe4 100644
--- a/backend/src/typeDefs.ts
+++ b/backend/src/typeDefs.ts
@@ -119,6 +119,11 @@ export const typeDefs = /* GraphQL */ `
text: String!
}
+ type DeleteReviewPayload {
+ deletedId: ID! # id-en som ble slettet; frontend fjerner Review: fra cachen
+ title: Title! # tittelen med oppdaterte userRating og reviewCount
+ }
+
type Query {
# query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel.
search(
@@ -139,5 +144,7 @@ export const typeDefs = /* GraphQL */ `
type Mutation {
addReview(input: AddReviewInput!): Review!
toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList
+ # Sletter bare brukerens egne anmeldelser. Andres og ukjente id-er gir begge NOT_FOUND.
+ deleteReview(id: ID!): DeleteReviewPayload!
}
`;
diff --git a/backend/test/delete-review.test.ts b/backend/test/delete-review.test.ts
new file mode 100644
index 0000000..56bd5d1
--- /dev/null
+++ b/backend/test/delete-review.test.ts
@@ -0,0 +1,106 @@
+/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */
+import { describe, expect, it } from 'vitest';
+import { setupApi, USER_A, USER_B } from './helpers.js';
+
+const env = setupApi();
+
+const ADD = /* GraphQL */ `
+ mutation Add($input: AddReviewInput!) {
+ addReview(input: $input) {
+ id
+ }
+ }
+`;
+const DELETE = /* GraphQL */ `
+ mutation Delete($id: ID!) {
+ deleteReview(id: $id) {
+ deletedId
+ title {
+ id
+ userRating
+ reviewCount
+ }
+ }
+ }
+`;
+const REVIEW_IDS = /* GraphQL */ `
+ query ($id: ID!) {
+ title(id: $id) {
+ reviews {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ }
+`;
+
+const titleId = 'tt0000101';
+const add = async (user: string, rating: number) =>
+ (await env.gql(ADD, { input: { titleId, author: 'Kari', rating, text: 'ok' } }, user)).data!
+ .addReview.id as string;
+const code = (r: { errors?: { extensions?: { code?: string } }[] }) =>
+ r.errors?.[0]?.extensions?.code;
+
+describe('deleteReview', () => {
+ it('sletter egen anmeldelse og returnerer id og oppdaterte aggregater', async () => {
+ const mine = await add(USER_A, 5);
+ const theirs = await add(USER_B, 3);
+ const res = await env.gql(DELETE, { id: mine }, USER_A);
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.deleteReview).toEqual({
+ deletedId: mine,
+ title: { id: titleId, userRating: 3, reviewCount: 1 },
+ });
+ const after = await env.gql(REVIEW_IDS, { id: titleId });
+ expect(after.data!.title.reviews.edges.map((e: any) => e.node.id)).toEqual([theirs]);
+ await env.gql(DELETE, { id: theirs }, USER_B);
+ });
+
+ it('nullstiller userRating når siste anmeldelse slettes', async () => {
+ const id = await add(USER_A, 4);
+ const res = await env.gql(DELETE, { id }, USER_A);
+ expect(res.data!.deleteReview.title).toEqual({ id: titleId, userRating: null, reviewCount: 0 });
+ });
+
+ it('gir NOT_FOUND for andres anmeldelse og lar den stå', async () => {
+ const theirs = await add(USER_B, 2);
+ const res = await env.gql(DELETE, { id: theirs }, USER_A);
+ expect(code(res)).toBe('NOT_FOUND');
+ expect(res.data).toBeNull();
+ const rows = await env.pool.query('SELECT 1 FROM reviews WHERE id = $1', [theirs]);
+ expect(rows.rowCount).toBe(1);
+ await env.gql(DELETE, { id: theirs }, USER_B);
+ });
+
+ it('gir samme svar for ukjent id som for andres (lekker ikke eksistens)', async () => {
+ const theirs = await add(USER_B, 2);
+ const other = await env.gql(DELETE, { id: theirs }, USER_A);
+ const unknown = await env.gql(DELETE, { id: '999999999' }, USER_A);
+ expect(code(unknown)).toBe('NOT_FOUND');
+ expect(unknown.errors![0]!.message).toBe(other.errors![0]!.message);
+ await env.gql(DELETE, { id: theirs }, USER_B);
+ });
+
+ it('kan ikke slette samme anmeldelse to ganger', async () => {
+ const id = await add(USER_A, 1);
+ expect((await env.gql(DELETE, { id }, USER_A)).errors).toBeUndefined();
+ expect(code(await env.gql(DELETE, { id }, USER_A))).toBe('NOT_FOUND');
+ });
+
+ it.each(['abc', '', '0', '-1', '1.5', '1; DROP TABLE reviews', '9'.repeat(30), '٣'])(
+ 'avviser ugyldig id %j med BAD_USER_INPUT',
+ async (id) => {
+ expect(code(await env.gql(DELETE, { id }, USER_A))).toBe('BAD_USER_INPUT');
+ },
+ );
+
+ it('gir UNAUTHENTICATED uten gyldig x-user-id', async () => {
+ const id = await add(USER_A, 4);
+ expect(code(await env.gql(DELETE, { id }))).toBe('UNAUTHENTICATED');
+ expect(code(await env.gql(DELETE, { id }, 'ugyldig'))).toBe('UNAUTHENTICATED');
+ expect((await env.gql(DELETE, { id }, USER_A)).errors).toBeUndefined();
+ });
+});
diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts
index d1f2194..6c4e3e4 100644
--- a/backend/test/helpers.ts
+++ b/backend/test/helpers.ts
@@ -4,6 +4,7 @@ import { ArtworkService } from '../src/artwork.js';
import { createApp } from '../src/app.js';
import { config } from '../src/config.js';
import { createPool, type Pool } from '../src/db.js';
+import type { RateLimitOptions } from '../src/rateLimit.js';
import { resetData } from './fixtures.js';
export const USER_A = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
@@ -27,7 +28,7 @@ export interface TestEnv {
* Felles oppsett for API-tester: egen pool mot testdatabasen, friskt datasett før filen,
* og spørringer via yoga.fetch (ingen nettverksport nødvendig).
*/
-export function setupApi(): TestEnv {
+export function setupApi(rateLimits: Partial = {}): TestEnv {
const env = {} as TestEnv;
beforeAll(async () => {
env.pool = createPool(config.testDatabaseUrl);
@@ -36,6 +37,8 @@ export function setupApi(): TestEnv {
pool: env.pool,
production: false,
corsOrigin: 'http://localhost:5173',
+ // Så høye grenser at funksjonelle tester aldri treffer dem; rate-limit.test.ts bruker egne.
+ rateLimits: { reviewsPerMinute: 10_000, mutationsPerMinute: 10_000, ...rateLimits },
// Uten nøkkel: testene skal aldri kunne nå et ekte TMDB, selv om utvikleren har en i .env.
artwork: new ArtworkService({ pool: env.pool, apiKey: undefined }),
});
diff --git a/backend/test/rate-limit.test.ts b/backend/test/rate-limit.test.ts
new file mode 100644
index 0000000..21a73ec
--- /dev/null
+++ b/backend/test/rate-limit.test.ts
@@ -0,0 +1,157 @@
+/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { createApp } from '../src/app.js';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+import { MutationLimiter, TokenBuckets, clientIp } from '../src/rateLimit.js';
+import { resetData } from './fixtures.js';
+import { USER_A, USER_B } from './helpers.js';
+
+describe('TokenBuckets', () => {
+ it('slipper gjennom `capacity` kall og avviser det neste med ventetid', () => {
+ let now = 0;
+ const b = new TokenBuckets(3, () => now);
+ expect([b.take('k'), b.take('k'), b.take('k')]).toEqual([0, 0, 0]);
+ // 3 per minutt = ett token per 20 s.
+ expect(b.take('k')).toBe(20);
+ now += 10_000;
+ expect(b.take('k')).toBe(10);
+ now += 10_000;
+ expect(b.take('k')).toBe(0);
+ });
+
+ it('fyller aldri over kapasiteten, og nøklene er uavhengige', () => {
+ let now = 0;
+ const b = new TokenBuckets(2, () => now);
+ b.take('a');
+ b.take('a');
+ expect(b.take('a')).toBeGreaterThan(0);
+ expect(b.take('b')).toBe(0);
+ now += 10 * 60_000;
+ expect([b.take('a'), b.take('a'), b.take('a')].map((w) => w > 0)).toEqual([false, false, true]);
+ });
+});
+
+describe('clientIp', () => {
+ const ip = (h?: string) =>
+ clientIp(new Request('http://x/', h ? { headers: { 'x-forwarded-for': h } } : {}));
+ it('bruker siste ledd, som proxyen la til', () => {
+ expect(ip('6.6.6.6, 203.0.113.9')).toBe('203.0.113.9');
+ expect(ip('203.0.113.9')).toBe('203.0.113.9');
+ expect(ip()).toBeNull();
+ expect(ip(' ')).toBeNull();
+ });
+});
+
+describe('MutationLimiter', () => {
+ it('begrenser per IP på tvers av brukere', () => {
+ const l = new MutationLimiter({ reviewsPerMinute: 2, mutationsPerMinute: 2, ipFactor: 2 });
+ // IP-grensen er 4. Hver bruker holder seg under sin egen grense på 2.
+ for (const u of ['u1', 'u2']) {
+ l.check('review', u, '1.1.1.1');
+ l.check('review', u, '1.1.1.1');
+ }
+ expect(() => l.check('review', 'u3', '1.1.1.1')).toThrow(/For mange/);
+ expect(() => l.check('review', 'u3', '2.2.2.2')).not.toThrow();
+ });
+
+ it('avvist forespørsel på IP bruker ikke opp brukerens kvote', () => {
+ const l = new MutationLimiter({ reviewsPerMinute: 1, mutationsPerMinute: 1, ipFactor: 1 });
+ l.check('review', 'u1', '1.1.1.1');
+ expect(() => l.check('review', 'u2', '1.1.1.1')).toThrow();
+ expect(() => l.check('review', 'u2', '9.9.9.9')).not.toThrow();
+ });
+});
+
+describe('begrensning av mutations via API-et', () => {
+ let pool: Pool;
+ beforeAll(async () => {
+ pool = createPool(config.testDatabaseUrl);
+ await resetData(pool);
+ });
+ afterAll(async () => {
+ await pool.end();
+ });
+
+ const call = async (
+ yoga: ReturnType,
+ query: string,
+ user: string,
+ headers: Record = {},
+ ) => {
+ const res = await yoga.fetch('http://localhost/graphql', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-user-id': user, ...headers },
+ body: JSON.stringify({ query }),
+ });
+ return (await res.json()) as { data?: any; errors?: { message: string; extensions: any }[] };
+ };
+ const add = (rating = 4) =>
+ `mutation { addReview(input: {titleId: "tt0000001", author: "A", rating: ${rating}, text: ""}) { id } }`;
+ const toggle = 'mutation { toggleList(titleId: "tt0000002") { inMyList } }';
+
+ it('gir RATE_LIMITED med forståelig melding og retryAfterSeconds når kvoten er brukt opp', async () => {
+ const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 3 } });
+ for (let i = 0; i < 3; i++) expect((await call(yoga, add(), USER_A)).errors).toBeUndefined();
+ const res = await call(yoga, add(), USER_A);
+ expect(res.data).toBeNull();
+ expect(res.errors![0]!.extensions.code).toBe('RATE_LIMITED');
+ expect(res.errors![0]!.message).toMatch(/For mange forsøk\. Vent \d+ sekunder/);
+ // 3 per minutt = ett token per 20 s.
+ expect(res.errors![0]!.extensions.retryAfterSeconds).toBeGreaterThan(0);
+ expect(res.errors![0]!.extensions.retryAfterSeconds).toBeLessThanOrEqual(20);
+ // Avviste forsøk lagrer ingenting.
+ const { rows } = await pool.query('SELECT count(*)::int AS n FROM reviews WHERE user_id = $1', [
+ USER_A,
+ ]);
+ expect(rows[0].n).toBe(3);
+ });
+
+ it('er per bruker: en annen bruker påvirkes ikke', async () => {
+ const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 1 } });
+ expect((await call(yoga, add(), USER_A)).errors).toBeUndefined();
+ expect((await call(yoga, add(), USER_A)).errors![0]!.extensions.code).toBe('RATE_LIMITED');
+ expect((await call(yoga, add(), USER_B)).errors).toBeUndefined();
+ });
+
+ it('teller også ugyldige forsøk, men ikke uautentiserte', async () => {
+ const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 2 } });
+ const invalid = add(9);
+ expect((await call(yoga, invalid, USER_A)).errors![0]!.extensions.code).toBe('BAD_USER_INPUT');
+ expect((await call(yoga, invalid, USER_A)).errors![0]!.extensions.code).toBe('BAD_USER_INPUT');
+ expect((await call(yoga, invalid, USER_A)).errors![0]!.extensions.code).toBe('RATE_LIMITED');
+ // Uten gyldig bruker svarer vi UNAUTHENTICATED uten å røre noen kvote.
+ const anon = await call(yoga, add(), 'ikke-uuid');
+ expect(anon.errors![0]!.extensions.code).toBe('UNAUTHENTICATED');
+ });
+
+ it('toggleList og deleteReview deler en egen kvote, atskilt fra anmeldelser', async () => {
+ const yoga = createApp({ pool, rateLimits: { mutationsPerMinute: 2, reviewsPerMinute: 100 } });
+ expect((await call(yoga, toggle, USER_A)).errors).toBeUndefined();
+ const del = await call(yoga, 'mutation { deleteReview(id: "999999") { deletedId } }', USER_A);
+ expect(del.errors![0]!.extensions.code).toBe('NOT_FOUND');
+ expect((await call(yoga, toggle, USER_A)).errors![0]!.extensions.code).toBe('RATE_LIMITED');
+ // Anmeldelser har egen bøtte og er upåvirket.
+ expect((await call(yoga, add(), USER_A)).errors).toBeUndefined();
+ });
+
+ it('begrenser per IP (X-Forwarded-For) selv om brukerne roterer', async () => {
+ const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 1, ipFactor: 2 } });
+ const xff = { 'x-forwarded-for': '203.0.113.7' };
+ const uuid = (n: number) => `00000000-0000-4000-8000-00000000000${n}`;
+ expect((await call(yoga, add(), uuid(1), xff)).errors).toBeUndefined();
+ expect((await call(yoga, add(), uuid(2), xff)).errors).toBeUndefined();
+ expect((await call(yoga, add(), uuid(3), xff)).errors![0]!.extensions.code).toBe(
+ 'RATE_LIMITED',
+ );
+ // En annen IP er upåvirket.
+ const other = await call(yoga, add(), uuid(3), { 'x-forwarded-for': '203.0.113.8' });
+ expect(other.errors).toBeUndefined();
+ });
+
+ it('standardgrensene tillater en rask runde på noen få anmeldelser og listeklikk', async () => {
+ const yoga = createApp({ pool });
+ for (let i = 0; i < 5; i++) expect((await call(yoga, add(), USER_A)).errors).toBeUndefined();
+ for (let i = 0; i < 10; i++) expect((await call(yoga, toggle, USER_A)).errors).toBeUndefined();
+ });
+});
diff --git a/docs/api.md b/docs/api.md
index 31e09e6..d18612a 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -128,6 +128,11 @@ input AddReviewInput {
text: String!
}
+type DeleteReviewPayload {
+ deletedId: ID! # id-en som ble slettet; frontend fjerner Review: fra cachen
+ title: Title! # tittelen med oppdaterte userRating og reviewCount
+}
+
type Query {
# query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel.
search(
@@ -148,6 +153,8 @@ type Query {
type Mutation {
addReview(input: AddReviewInput!): Review!
toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList
+ # Sletter bare brukerens egne anmeldelser. Andres og ukjente id-er gir begge NOT_FOUND.
+ deleteReview(id: ID!): DeleteReviewPayload!
}
```
@@ -156,7 +163,18 @@ type Mutation {
- `first` må være 1–50, ellers `BAD_USER_INPUT`. `query` maks 200 tegn. Spørredybde maks 6.
- Cursorer er ugjennomsiktige strenger (base64). Ugyldig cursor gir `BAD_USER_INPUT`.
- Feil returneres som GraphQL-feil med `extensions.code`: `BAD_USER_INPUT`, `NOT_FOUND`,
- `UNAUTHENTICATED` (mangler `x-user-id` på mutation), `INTERNAL_SERVER_ERROR` (uten detaljer).
+ `UNAUTHENTICATED` (mangler `x-user-id` på mutation), `RATE_LIMITED` (se under),
+ `INTERNAL_SERVER_ERROR` (uten detaljer).
+- Maks 8 rotfelt (aliaser telles hver for seg) og 150 felt totalt per operasjon (fragmenter
+ ekspandert), ellers `BAD_USER_INPUT`. Frontendens største spørring har ca. 45 felt.
+- `deleteReview(id)`: `id` må være heltallsstrengen fra `Review.id` (ellers `BAD_USER_INPUT`).
+ Hører anmeldelsen til en annen `x-user-id`, eller finnes den ikke, er svaret likt: `NOT_FOUND`
+ («Fant ikke anmeldelsen.»). Svaret har `deletedId` (fjern `Review:` fra Apollo-cachen) og
+ `title` med nye `userRating`/`reviewCount`, som oppdaterer `Title:` i cachen automatisk.
+- Begrensning av mutations (token bucket i prosessminnet, per `x-user-id` og per IP, se
+ `docs/beslutninger.md`): `addReview` maks 10 per minutt per bruker, `toggleList` og `deleteReview`
+ maks 60 per minutt per bruker; IP-grensen er tre ganger så høy. Over grensen gir `RATE_LIMITED`
+ med `extensions.retryAfterSeconds` og meldingen «For mange forsøk. Vent N sekunder og prøv igjen.»
- All søk, filtrering, sortering og paginering skjer i SQL.
## Bilder (TMDB)
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
index 2644ba9..1f49401 100644
--- a/docs/beslutninger.md
+++ b/docs/beslutninger.md
@@ -174,3 +174,29 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
- **Volum og demping huskes i `localStorage`** (`filmsok:player`); ødelagt verdi gir standard.
- **Tilbake-lenken på spillersiden går alltid til `/title/:id`** (deterministisk, også ved direkte åpning).
- **Mobil skjuler volumslideren** (maskinvareknapper); demp-knappen finnes.
+
+## Herding av backend (issue #15)
+
+- **Feltgrense i tillegg til dybdegrensen.** `complexityLimit` (`backend/src/complexityLimit.ts`) avviser
+ operasjoner med mer enn 8 rotfelt eller 150 felt totalt (aliaser og ekspanderte fragmenter telt
+ hver for seg). Dybdegrensen stoppet bare nøsting, så ett flatt dokument med hundrevis av aliasede
+ `search` (én SQL-spørring hver) eller `posterUrl` (TMDB) slapp gjennom. Frontendens største spørring
+ (Title-detaljer) har ca. 45 felt og ett rotfelt, så grensene har rundt tre ganger slingringsmonn;
+ `server.test.ts` har kopier av de to største spørringene som vaktbikkje. Fragmenttellingen er
+ memoisert, slik at en fragmentkjede med eksponentiell utvidelse ikke kan brukes til å låse CPU-en
+ under selve valideringen.
+- **Begrensning av mutations: token bucket i prosessminnet** (`backend/src/rateLimit.ts`), per
+ `x-user-id` og i tillegg per IP (siste ledd i `X-Forwarded-For`, som Apache legger til; uten
+ proxy hoppes IP-grensen over). Én bøtte for `addReview` (10/min) og én for `toggleList` og
+ `deleteReview` (60/min); IP-grensen er 3 ganger brukergrensen. Per-IP trengs fordi `x-user-id` er
+ anonym og trivielt å rotere. Overskridelse gir `RATE_LIMITED` med `retryAfterSeconds`.
+ Begrensning: tilstanden er per prosess og nullstilles ved restart. Det er greit for én
+ systemd-tjeneste på VM-en; flere instanser ville krevd delt lager (Redis/tabell), som er overkill her.
+ Grensene er romslige nok for E2E-testene og vanlig bruk. Minnet er avgrenset (50 000 nøkler per bøtte).
+- **`deleteReview(id)` returnerer `{ deletedId, title }`.** Eierskapet sjekkes i selve
+ `DELETE ... WHERE id AND user_id`, og både «andres» og «finnes ikke» gir `NOT_FOUND` med lik melding
+ slik at mutationen ikke kan brukes til å lete etter anmeldelser. `title` gir ferdig oppdaterte
+ `userRating`/`reviewCount` som Apollo skriver inn i `Title:` uten refetch; `deletedId` brukes til
+ å evicte `Review:`. Ugyldig id (ikke 1–18 siffer) gir `BAD_USER_INPUT`.
+- **Tittel-id valideres som `^tt\d{7,10}$`** (`isPlausibleTitleId`). Fixture, testdata og
+ Archive-mock bruker bare 7 siffer; IMDb har 7–8 i dag, 10 gir slingringsmonn.
From 86e35a7ab21f33f54242ba6133df2a87f3feff8d Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 18:01:16 +0000
Subject: [PATCH 005/132] =?UTF-8?q?fix(frontend):=20rename=20availability?=
=?UTF-8?q?=20filter=20to=20'Kun=20filmer=20som=20kan=20str=C3=B8mmes=20gr?=
=?UTF-8?q?atis'?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
PLAN.md | 2 +-
e2e/a11y.spec.ts | 6 +++++-
e2e/watch.spec.ts | 8 +++++---
frontend/src/components/ActiveFilters.tsx | 2 +-
frontend/src/components/FilterPanel.tsx | 2 +-
frontend/src/lib/searchState.ts | 2 +-
6 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/PLAN.md b/PLAN.md
index 3fef19a..2bc66f5 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -62,7 +62,7 @@ lenker til og strømmer fra archive.org og lagrer ingen video selv.
- [x] Backend: tabell `title_streams` (title_id, archive_id, fil, lisens, varighet) + migrering
- [x] Importskript `db:archive`: hent filmer fra Internet Archive-samlinger med fri lisens, koble til IMDb-titler via IMDb-ID i metadata (ellers tittel + år), velg en MP4-fil som nettlesere kan spille. Idempotent, testet mot falsk Archive-server
- [x] API: `Title.stream { url, license, archiveUrl }` og filter `availableOnly` (+ fasettantall)
-- [x] Frontend: filter «Kun filmer du kan se», rad «Se gratis nå» på forsiden, merke på plakater
+- [x] Frontend: filter «Kun filmer som kan strømmes gratis», rad «Se gratis nå» på forsiden, merke på plakater
- [x] Videospiller på `/watch/:id`: spill/pause, spoling (tidslinje og ±10 s), volum og demping, fullskjerm, tastatur (mellomrom, piler, M, F), undertekster når de finnes, lisens og kilde under spilleren
- [x] Tester: import og kobling, API, spillerkontroller (komponent), E2E med liten testvideo, axe
- [x] Deploy: `setup-vm.sh` kjører `db:archive` etter importen
diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index 46c0357..cdb6270 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -4,7 +4,11 @@ import { expect, test } from '@playwright/test';
// WCAG 2.1 AA er kravet i CLAUDE.md; axe sjekker det som kan sjekkes automatisk.
const pages = [
{ name: 'forside (bla-modus)', path: './', ready: 'Søk i filmer og serier', scrollAll: true },
- { name: 'søk med kun filmer du kan se', path: './?available=1', ready: 'Søk i filmer og serier' },
+ {
+ name: 'søk med kun filmer som kan strømmes gratis',
+ path: './?available=1',
+ ready: 'Søk i filmer og serier',
+ },
{ name: 'søk uten tekst', path: './?sort=relevans', ready: 'Søk i filmer og serier' },
{
name: 'søk med treff og filter',
diff --git a/e2e/watch.spec.ts b/e2e/watch.spec.ts
index e116859..b9234d6 100644
--- a/e2e/watch.spec.ts
+++ b/e2e/watch.spec.ts
@@ -37,20 +37,22 @@ async function waitForMetadata(page: Page) {
await expect(page.getByText('Laster …')).toHaveCount(0);
}
-test('filtrer på filmer du kan se, åpne en film og spill den av med tastatur', async ({ page }) => {
+test('filtrer på filmer som kan strømmes gratis, åpne en film og spill den av med tastatur', async ({
+ page,
+}) => {
await page.goto('./?sort=relevans');
const count = page.locator('.count[role="status"]');
await expect(count).toHaveText(/\d[\d\s]* treff/);
const allCount = await count.textContent();
const filters = await openFilters(page);
- const only = filters.getByRole('checkbox', { name: /^Kun filmer du kan se/ });
+ const only = filters.getByRole('checkbox', { name: /^Kun filmer som kan strømmes gratis/ });
// Antallet står i etiketten og kommer fra fasettene (SQL), ikke fra klienten.
await expect(only).toHaveAccessibleName(/\(\d+\)/);
await only.check();
await expect(page).toHaveURL(/available=1/);
await expect(page.getByRole('list', { name: 'Aktive filtre' })).toContainText(
- 'Kun filmer du kan se',
+ 'Kun filmer som kan strømmes gratis',
);
await expect(count).not.toHaveText(allCount ?? '');
await expect(page.locator('main ul.is-stale')).toHaveCount(0);
diff --git a/frontend/src/components/ActiveFilters.tsx b/frontend/src/components/ActiveFilters.tsx
index 5905376..62606d5 100644
--- a/frontend/src/components/ActiveFilters.tsx
+++ b/frontend/src/components/ActiveFilters.tsx
@@ -30,7 +30,7 @@ export function ActiveFilters({ state, onChange, onReset }: Props) {
if (state.available) {
chips.unshift({
key: 'available',
- label: 'Kun filmer du kan se',
+ label: 'Kun filmer som kan strømmes gratis',
remove: () => onChange({ available: false }),
});
}
diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx
index 6121612..6872528 100644
--- a/frontend/src/components/FilterPanel.tsx
+++ b/frontend/src/components/FilterPanel.tsx
@@ -75,7 +75,7 @@ export function FilterPanel({ state, onChange }: Props) {
onChange={() => onChange({ available: !state.available })}
/>
- Kun filmer du kan se
+ Kun filmer som kan strømmes gratis
{suffix(facetData?.facets.available ?? (facetData ? 0 : undefined))}
diff --git a/frontend/src/lib/searchState.ts b/frontend/src/lib/searchState.ts
index 71dcc0f..55f733e 100644
--- a/frontend/src/lib/searchState.ts
+++ b/frontend/src/lib/searchState.ts
@@ -13,7 +13,7 @@ export interface SearchState {
decades: number[];
types: TitleType[];
minRating: number | null;
- /** Bare titler med lovlig strøm («Kun filmer du kan se»). */
+ /** Bare titler med lovlig strøm («Kun filmer som kan strømmes gratis»). */
available: boolean;
/** null = ikke valgt, serveren bruker sin standard (relevans). */
sort: SortField | null;
From 07e6c180d144ab1166da76067993ce388dc6a5cc Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 18:01:21 +0000
Subject: [PATCH 006/132] fix(frontend): commit URL filter changes
synchronously so checkboxes do not snap back
---
frontend/src/hooks/useSearchState.ts | 4 ++++
frontend/src/main.tsx | 4 +++-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/frontend/src/hooks/useSearchState.ts b/frontend/src/hooks/useSearchState.ts
index d3d1c8e..5f3d20a 100644
--- a/frontend/src/hooks/useSearchState.ts
+++ b/frontend/src/hooks/useSearchState.ts
@@ -14,6 +14,10 @@ export function useSearchState() {
(patch: Partial) => {
setParams((prev) => serializeSearchState({ ...parseSearchState(prev), ...patch }), {
replace: true,
+ // React Router pakker ellers URL-endringen i en transition. Avkrysningsboksene er kontrollert
+ // av URL-en, så React tilbakestiller dem til gammel verdi helt til transitionen er ferdig,
+ // og brukeren (og hjelpemidler) ser en boks som «hopper tilbake» i et øyeblikk etter klikket.
+ flushSync: true,
});
},
[setParams],
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 2e35110..bd1a66e 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,6 +1,8 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { createBrowserRouter, RouterProvider } from 'react-router';
+import { createBrowserRouter } from 'react-router';
+// Varianten fra /dom kobler på ReactDOM.flushSync, som `useSearchState` trenger (se der).
+import { RouterProvider } from 'react-router/dom';
import { ApolloProvider } from '@apollo/client/react';
import { createClient } from './apollo/client';
import { routes } from './routes';
From c513894dd163fe79712c5da7621d7b2c02a07847 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 6 Oct 2026 18:01:24 +0000
Subject: [PATCH 007/132] feat(frontend): add category jump navigation to the
browse view
---
e2e/category-nav.spec.ts | 34 ++++++++++++
frontend/src/components/CategoryNav.tsx | 48 +++++++++++++++++
frontend/src/components/LazyRow.tsx | 5 +-
frontend/src/lib/browseRows.ts | 3 ++
frontend/src/pages/HomePage.test.tsx | 71 ++++++++++++++++++++++---
frontend/src/pages/HomePage.tsx | 22 ++++----
frontend/src/styles/global.css | 49 ++++++++++++++++-
7 files changed, 213 insertions(+), 19 deletions(-)
create mode 100644 e2e/category-nav.spec.ts
create mode 100644 frontend/src/components/CategoryNav.tsx
diff --git a/e2e/category-nav.spec.ts b/e2e/category-nav.spec.ts
new file mode 100644
index 0000000..f5d487d
--- /dev/null
+++ b/e2e/category-nav.spec.ts
@@ -0,0 +1,34 @@
+import { expect, test } from '@playwright/test';
+
+test('hopp til en rad langt nede med tastatur: overskriften får fokus og raden laster titler', async ({
+ page,
+}) => {
+ await page.goto('./');
+ await expect(page.getByRole('region', { name: 'Mest populære' })).toBeVisible();
+ const nav = page.getByRole('navigation', { name: 'Kategorier' });
+ const link = nav.getByRole('link', { name: 'Science fiction' });
+
+ // Fokus og Enter, slik et tastatur- eller skjermleserbrukere gjør det.
+ await link.focus();
+ await page.keyboard.press('Enter');
+
+ const heading = page.getByRole('heading', { level: 2, name: 'Science fiction' });
+ await expect(heading).toBeFocused();
+ // Raden var en tom plassholder før hoppet; scrollingen må ha utløst lastingen.
+ await expect(
+ page.getByRole('region', { name: 'Science fiction' }).getByRole('article').first(),
+ ).toBeVisible();
+ // Overskriften skal ikke ligge skjult bak den faste headeren.
+ const headerBottom = await page
+ .locator('.site-header')
+ .evaluate((el) => el.getBoundingClientRect().bottom);
+ await expect
+ .poll(async () => heading.evaluate((el) => el.getBoundingClientRect().top))
+ .toBeGreaterThanOrEqual(headerBottom - 1);
+ // Siden skal aldri få horisontal scroll, heller ikke på 320 px (chipsene scroller inni nav-en).
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+});
diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx
new file mode 100644
index 0000000..40982f8
--- /dev/null
+++ b/frontend/src/components/CategoryNav.tsx
@@ -0,0 +1,48 @@
+import type { MouseEvent } from 'react';
+import { rowAnchorId, type BrowseRow } from '../lib/browseRows';
+
+const prefersReducedMotion = () =>
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+function jumpTo(id: string) {
+ const target = document.getElementById(id);
+ if (!target) return;
+ // Scroll først: da nærmer raden seg viewport og LazyRow starter hentingen av seg selv.
+ // `scrollIntoView` kan mangle i testmiljø; hoppet er en forbedring, fokus er det viktige.
+ target.scrollIntoView?.({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' });
+ // Overskriften er ikke fokuserbar fra før. tabIndex=-1 lar oss flytte fokus dit uten å legge
+ // den inn i tab-rekkefølgen. `preventScroll` hindrer at nettleseren avbryter den myke scrollingen.
+ const heading = target.querySelector('h2') ?? target;
+ heading.tabIndex = -1;
+ heading.focus({ preventScroll: true });
+}
+
+/**
+ * Kompakt hoppmeny rett under heltebanneret, så man slipper å scrolle forbi alle radene.
+ * Lenkene er ekte ankerlenker (href), men klikket håndteres her fordi vi også må flytte fokus.
+ */
+export function CategoryNav({ rows }: { rows: BrowseRow[] }) {
+ const onClick = (e: MouseEvent, row: BrowseRow) => {
+ e.preventDefault();
+ jumpTo(rowAnchorId(row));
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/LazyRow.tsx b/frontend/src/components/LazyRow.tsx
index bbe15aa..ff093c2 100644
--- a/frontend/src/components/LazyRow.tsx
+++ b/frontend/src/components/LazyRow.tsx
@@ -2,7 +2,7 @@ import { useRef } from 'react';
import { useQuery } from '@apollo/client/react';
import { SEARCH_QUERY } from '../graphql/operations';
import { useNearViewport } from '../hooks/useNearViewport';
-import { rowVariables, seeAllSearch, type BrowseRow } from '../lib/browseRows';
+import { rowAnchorId, rowVariables, seeAllSearch, type BrowseRow } from '../lib/browseRows';
import { TitleRow } from './TitleRow';
/**
@@ -18,7 +18,8 @@ export function LazyRow({ row }: { row: BrowseRow }) {
});
return (
-
+ // Id og scroll-margin lar kategorinavigasjonen hoppe hit forbi den faste headeren.
+
) => `rad-${row.id}`;
+
export const ROW_SIZE = 20;
export function rowVariables(state: SearchState) {
diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx
index 9b9e53f..dc27f1b 100644
--- a/frontend/src/pages/HomePage.test.tsx
+++ b/frontend/src/pages/HomePage.test.tsx
@@ -14,6 +14,7 @@ import {
renderApp,
type Vars,
} from '../test/utils';
+import { BROWSE_ROWS } from '../lib/browseRows';
import { HeaderSearch } from '../components/HeaderSearch';
import HomePage from './HomePage';
@@ -187,14 +188,16 @@ describe('HomePage: filtre', () => {
await waitFor(() => expect(log.Search.at(-1)).toEqual({ query: 'alien', first: 20 }));
});
- it('«Kun filmer du kan se» står først, viser antall og oppdaterer URL, variabler og chip', async () => {
+ it('«Kun filmer som kan strømmes gratis» står først, viser antall og oppdaterer URL, variabler og chip', async () => {
const { log, user } = setup();
- const box = await screen.findByRole('checkbox', { name: /^Kun filmer du kan se/ });
+ const box = await screen.findByRole('checkbox', {
+ name: /^Kun filmer som kan strømmes gratis/,
+ });
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[0]).toBe(box);
await waitFor(() =>
expect(box.closest('label')!.textContent!.replace(/\s/g, ' ')).toBe(
- 'Kun filmer du kan se (3)',
+ 'Kun filmer som kan strømmes gratis (3)',
),
);
expect(box).not.toBeChecked();
@@ -209,16 +212,20 @@ describe('HomePage: filtre', () => {
expect(log.Facets.at(-1)).toMatchObject({ filters: { availableOnly: true } }),
);
const chips = screen.getByRole('list', { name: 'Aktive filtre' });
- const chip = within(chips).getByRole('button', { name: /Kun filmer du kan se/ });
+ const chip = within(chips).getByRole('button', { name: /Kun filmer som kan strømmes gratis/ });
await user.click(chip);
await waitFor(() => expect(currentUrl().searchParams.has('available')).toBe(false));
- expect(screen.getByRole('checkbox', { name: /^Kun filmer du kan se/ })).not.toBeChecked();
+ expect(
+ screen.getByRole('checkbox', { name: /^Kun filmer som kan strømmes gratis/ }),
+ ).not.toBeChecked();
});
it('leser available=1 fra URL, og «Nullstill alle» fjerner den', async () => {
const { user, log } = setup('/?q=alien&available=1');
- expect(await screen.findByRole('checkbox', { name: /^Kun filmer du kan se/ })).toBeChecked();
+ expect(
+ await screen.findByRole('checkbox', { name: /^Kun filmer som kan strømmes gratis/ }),
+ ).toBeChecked();
await waitFor(() =>
expect(log.Search.at(-1)).toMatchObject({
query: 'alien',
@@ -359,7 +366,9 @@ describe('HomePage: bla-modus', () => {
expect(link.getAttribute('href')).toMatch(/^\/\?available=1/);
await user.click(link);
await waitFor(() => expect(currentUrl().searchParams.get('available')).toBe('1'));
- expect(await screen.findByRole('checkbox', { name: /^Kun filmer du kan se/ })).toBeChecked();
+ expect(
+ await screen.findByRole('checkbox', { name: /^Kun filmer som kan strømmes gratis/ }),
+ ).toBeChecked();
});
it('skjuler «Se gratis nå» når ingen filmer kan sees', async () => {
@@ -432,4 +441,52 @@ describe('HomePage: bla-modus', () => {
expect(screen.getByRole('button', { name: 'Prøv igjen' })).toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Med bilde' })).not.toBeInTheDocument();
});
+
+ it('har kategorinavigasjon med én lenke per rad, etter heltebanneret og før radene', async () => {
+ stubIntersectionObserver();
+ setup('/');
+ const hero = await screen.findByRole('region', { name: 'Med bilde' });
+ const nav = screen.getByRole('navigation', { name: 'Kategorier' });
+ const links = within(nav).getAllByRole('link');
+ expect(links.map((a) => a.textContent)).toEqual(BROWSE_ROWS.map((r) => r.heading));
+ expect(links[0]).toHaveAttribute('href', '#rad-popular');
+ // Hero, så nav, så første rad i dokumentrekkefølge.
+ const firstRow = screen.getByRole('heading', { name: 'Mest populære' });
+ expect(hero.compareDocumentPosition(nav) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(nav.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('flytter fokus til radens overskrift ved klikk og Enter, uten å endre URL', async () => {
+ stubIntersectionObserver();
+ const { user } = setup('/');
+ await screen.findByRole('region', { name: 'Med bilde' });
+ const nav = screen.getByRole('navigation', { name: 'Kategorier' });
+ const before = currentUrl().href;
+
+ await user.click(within(nav).getByRole('link', { name: 'Drama' }));
+ const drama = screen.getByRole('heading', { name: 'Drama' });
+ expect(drama).toHaveFocus();
+ expect(drama).toHaveAttribute('tabindex', '-1');
+
+ within(nav).getByRole('link', { name: 'Komedie' }).focus();
+ await user.keyboard('{Enter}');
+ expect(screen.getByRole('heading', { name: 'Komedie' })).toHaveFocus();
+ expect(currentUrl().href).toBe(before);
+ });
+
+ it('hopp til en rad under folden starter hentingen når raden kommer i viewport', async () => {
+ const io = stubIntersectionObserver();
+ const { log, user } = setup('/');
+ await screen.findByRole('region', { name: 'Med bilde' });
+ expect(log.Search).toHaveLength(0);
+ await user.click(
+ within(screen.getByRole('navigation', { name: 'Kategorier' })).getByRole('link', {
+ name: 'Science fiction',
+ }),
+ );
+ // Nettleseren melder raden som synlig etter scrollingen.
+ io.reveal(document.getElementById('rad-scifi')!);
+ await waitFor(() => expect(log.Search).toHaveLength(1));
+ expect(log.Search[0]).toMatchObject({ filters: { genres: ['Sci-Fi'] } });
+ });
});
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 5ec22c3..54f0b62 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -1,5 +1,6 @@
import { useQuery } from '@apollo/client/react';
import { ActiveFilters } from '../components/ActiveFilters';
+import { CategoryNav } from '../components/CategoryNav';
import { FilterPanel } from '../components/FilterPanel';
import { Hero, HeroSkeleton } from '../components/Hero';
import { LazyRow } from '../components/LazyRow';
@@ -9,7 +10,7 @@ import { TitleRow } from '../components/TitleRow';
import { FEATURED_QUERY } from '../graphql/operations';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import { useSearchState } from '../hooks/useSearchState';
-import { BROWSE_ROWS, rowVariables, seeAllSearch } from '../lib/browseRows';
+import { BROWSE_ROWS, rowAnchorId, rowVariables, seeAllSearch } from '../lib/browseRows';
import { isBrowseState, type SearchState } from '../lib/searchState';
const [FEATURED_ROW, ...OTHER_ROWS] = BROWSE_ROWS;
@@ -27,15 +28,18 @@ function BrowseView() {
<>
Søk i filmer og serier
{hero ? : loading ? : null}
+
- void refetch().catch(() => undefined)}
- />
+
+ void refetch().catch(() => undefined)}
+ />
+
{OTHER_ROWS.map((row) => (
))}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bc0e915..96e105c 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1073,7 +1073,7 @@ fieldset {
}
}
-/* «Kun filmer du kan se» står øverst og skilles fra fasettene under. */
+/* «Kun filmer som kan strømmes gratis» står øverst og skilles fra fasettene under. */
.check--available {
padding-bottom: 0.75rem;
margin-bottom: 0.5rem;
@@ -1405,3 +1405,50 @@ fieldset {
aspect-ratio: auto;
border-radius: 0;
}
+
+/* === Kategorinavigasjon på forsiden (#14) – samlet her for enkel merge === */
+.catnav {
+ padding-top: 0.75rem;
+}
+.catnav__list {
+ display: flex;
+ gap: 0.5rem;
+ list-style: none;
+ margin: 0;
+ padding: 0.25rem 0.25rem 0.5rem;
+ /* Smal skjerm: én rad som scroller sidelengs inni navigasjonen, så siden selv aldri får sidescroll. */
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+}
+.catnav__list li {
+ flex: none;
+}
+@media (min-width: 48rem) {
+ .catnav__list {
+ flex-wrap: wrap;
+ overflow-x: visible;
+ }
+}
+.catnav__link {
+ display: inline-flex;
+ align-items: center;
+ min-height: 2.25rem;
+ padding: 0.2rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text);
+ text-decoration: none;
+ white-space: nowrap;
+}
+.catnav__link:hover {
+ border-color: var(--accent-text);
+}
+/* Rader og overskrifter lander under den faste headeren ved hopp og fokus. */
+.row-anchor {
+ scroll-margin-top: calc(var(--header-h) + 0.5rem);
+}
+.row-anchor h2:focus-visible {
+ outline-offset: 4px;
+}
+/* === Slutt: kategorinavigasjon === */
From 112f4ecd4a6c5c02ea3832d384878e8d50e4620d Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:03:48 +0000
Subject: [PATCH 008/132] feat(frontend): explain unreachable API with one
global banner (#13)
Error link sets a reactive var on network failures (fetch error, 502/503/504, non-JSON), cleared on the next successful response. Layout shows one alert with NTNU VPN hint and a retry button; rows stay quiet meanwhile. Backend gets GET /health (SELECT 1, 2 s timeout), Apache proxies it, systemd unit restarts always and requires postgres.
---
backend/src/app.ts | 40 +++++++-
backend/test/server.test.ts | 20 ++++
deploy/apache-project2.conf | 6 ++
deploy/project2-backend.service | 7 +-
deploy/sjekk.sh | 4 +
docs/beslutninger.md | 7 ++
docs/deploy.md | 4 +
docs/ki-logg.md | 1 +
e2e/api-unavailable.spec.ts | 53 ++++++++++
frontend/package.json | 3 +-
frontend/src/apollo/apiStatus.ts | 31 ++++++
frontend/src/apollo/client.test.ts | 63 ++++++++++++
frontend/src/apollo/client.ts | 20 +++-
.../components/ApiUnavailableBanner.test.tsx | 56 +++++++++++
.../src/components/ApiUnavailableBanner.tsx | 45 +++++++++
frontend/src/components/Layout.tsx | 2 +
frontend/src/components/SearchResults.tsx | 6 +-
frontend/src/components/TitleRow.tsx | 7 +-
frontend/src/pages/HomePage.offline.test.tsx | 98 +++++++++++++++++++
frontend/src/styles/global.css | 25 +++++
package-lock.json | 3 +-
21 files changed, 492 insertions(+), 9 deletions(-)
create mode 100644 e2e/api-unavailable.spec.ts
create mode 100644 frontend/src/apollo/apiStatus.ts
create mode 100644 frontend/src/components/ApiUnavailableBanner.test.tsx
create mode 100644 frontend/src/components/ApiUnavailableBanner.tsx
create mode 100644 frontend/src/pages/HomePage.offline.test.tsx
diff --git a/backend/src/app.ts b/backend/src/app.ts
index c4facf2..547ce9c 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -26,6 +26,28 @@ const noIntrospection: ValidationRule = (context) => ({
},
});
+const HEALTH_TIMEOUT_MS = 2000;
+
+/**
+ * Kort tidsgrense utenom poolens egen: en database bak en brannmur svarer ikke i det hele tatt,
+ * og helsesjekken skal da svare 503 raskt i stedet for å henge til Apache gir opp.
+ */
+async function databaseResponds(pool: Pool, timeoutMs = HEALTH_TIMEOUT_MS): Promise {
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(false), timeoutMs);
+ });
+ const query = pool.query('SELECT 1').then(
+ () => true,
+ () => false,
+ );
+ try {
+ return await Promise.race([query, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
export interface AppOptions {
pool: Pool;
/** Slår av introspeksjon og GraphiQL. Standard: NODE_ENV === 'production'. */
@@ -60,10 +82,26 @@ export function createApp({
},
};
+ // Yogas innebygde /health svarer «alive» uten å se på databasen. Vi trenger det motsatte: frontend
+ // og sjekk.sh skal få vite om API-et faktisk kan svare, så Yogas flyttes og vår tar over stien.
+ const health: Plugin = {
+ async onRequest({ request, url, endResponse }) {
+ if (url.pathname !== '/health' || request.method !== 'GET') return;
+ const ok = await databaseResponds(pool);
+ endResponse(
+ Response.json(
+ { status: ok ? 'ok' : 'db-unavailable' },
+ { status: ok ? 200 : 503, headers: { 'cache-control': 'no-store' } },
+ ),
+ );
+ },
+ };
+
return createYoga
+ Laster …}>
diff --git a/frontend/src/components/SearchResults.tsx b/frontend/src/components/SearchResults.tsx
index e103fc6..c3de6be 100644
--- a/frontend/src/components/SearchResults.tsx
+++ b/frontend/src/components/SearchResults.tsx
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
import { useQuery } from '@apollo/client/react';
+import { useApiUnavailable } from '../apollo/apiStatus';
import { SEARCH_QUERY } from '../graphql/operations';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
import { formatNumber } from '../lib/format';
@@ -16,6 +17,7 @@ interface Props {
}
export function SearchResults({ state, onReset }: Props) {
+ const apiDown = useApiUnavailable();
const { data, previousData, loading, error, fetchMore, refetch } = useQuery(SEARCH_QUERY, {
variables: {
query: state.q || null,
@@ -52,6 +54,8 @@ export function SearchResults({ state, onReset }: Props) {
useInfiniteScroll(sentinel, () => void loadMore(), hasNext && !moreFailed, endCursor);
if (error && !result) {
+ // Ved nedetid sier banneret i Layout allerede fra; ikke ny alert og ikke ny «Prøv igjen».
+ if (apiDown) return
Søket er ikke tilgjengelig akkurat nå.
;
return (
{moreFailed && (
-
+
Kunne ikke laste flere.
)}
diff --git a/frontend/src/components/TitleRow.tsx b/frontend/src/components/TitleRow.tsx
index 475a6e6..143d535 100644
--- a/frontend/src/components/TitleRow.tsx
+++ b/frontend/src/components/TitleRow.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { Link } from 'react-router';
+import { useApiUnavailable } from '../apollo/apiStatus';
import type { TitleSummary } from '../graphql/types';
import { ErrorMessage } from './ErrorMessage';
import { PosterCard } from './PosterCard';
@@ -22,6 +23,7 @@ const prefersReducedMotion = () =>
/** Horisontal rad med plakater: scroll-snap, pil-knapper og piltaster mellom kortene. */
export function TitleRow({ heading, seeAllTo, titles, loading, error, onRetry }: Props) {
const headingId = useId();
+ const apiDown = useApiUnavailable();
const list = useRef(null);
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
@@ -80,12 +82,13 @@ export function TitleRow({ heading, seeAllTo, titles, loading, error, onRetry }:
- {error && !titles && (
+ {/* Ved nedetid kunngjør det globale banneret feilen; hver rad skal ikke gjenta den. */}
+ {error && !titles && !apiDown && (
diff --git a/frontend/src/pages/HomePage.offline.test.tsx b/frontend/src/pages/HomePage.offline.test.tsx
new file mode 100644
index 0000000..3a3e07a
--- /dev/null
+++ b/frontend/src/pages/HomePage.offline.test.tsx
@@ -0,0 +1,98 @@
+import { ApolloProvider } from '@apollo/client/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider } from 'react-router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { apiUnavailable } from '../apollo/apiStatus';
+import { createClient } from '../apollo/client';
+import { Layout } from '../components/Layout';
+import { makeConnection, makeFeatured } from '../test/utils';
+import HomePage from './HomePage';
+
+// Ekte klient med ekte linkkjede og falsk fetch: da testes også error-linken i sammenheng.
+function setup() {
+ const router = createMemoryRouter([
+ { element: , children: [{ index: true, element: }] },
+ ]);
+ render(
+
+
+ ,
+ );
+}
+
+const json = (body: unknown, status = 200) =>
+ Promise.resolve(
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.stubGlobal(
+ 'IntersectionObserver',
+ class {
+ observe = () => undefined;
+ unobserve = () => undefined;
+ disconnect = () => undefined;
+ takeRecords = () => [];
+ },
+ );
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+ act(() => void apiUnavailable(false));
+});
+
+describe('forsiden når API-et ikke kan nås', () => {
+ it('viser ÉN alert (banneret), ikke én feil per rad', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
+ setup();
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent('NTNU VPN');
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
+ expect(screen.queryByText('Kunne ikke hente denne raden.')).not.toBeInTheDocument();
+ // Raden finnes fortsatt, men som stille skjelett.
+ expect(screen.getByRole('region', { name: 'Mest populære' })).toBeInTheDocument();
+ });
+
+ it('behandler 502 fra proxyen som nedetid', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.resolve(new Response('Bad gateway', { status: 502 }))),
+ );
+ setup();
+ expect(await screen.findByRole('alert')).toHaveTextContent('Får ikke kontakt med serveren');
+ });
+
+ it('«Prøv igjen» henter data og fjerner banneret', async () => {
+ const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
+ vi.stubGlobal('fetch', fetchMock);
+ setup();
+ await screen.findByRole('alert');
+
+ fetchMock.mockImplementation(() =>
+ json({ data: { search: makeConnection([makeFeatured(1), makeFeatured(2)]) } }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' }));
+
+ await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+ const row = screen.getByRole('region', { name: 'Mest populære' });
+ expect(await within(row).findAllByRole('article')).toHaveLength(2);
+ });
+
+ it('en GraphQL-feil er ikke nedetid: per-rad-feil og ingen banner', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => json({ errors: [{ message: 'Ugyldig input' }] })),
+ );
+ setup();
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent('Kunne ikke hente denne raden.');
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
+ expect(screen.queryByText(/NTNU VPN/)).not.toBeInTheDocument();
+ expect(within(alert).getByRole('button', { name: 'Prøv igjen' })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bc0e915..5d6f417 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1405,3 +1405,28 @@ fieldset {
aspect-ratio: auto;
border-radius: 0;
}
+
+/* Globalt banner når API-et ikke kan nås. Bruker temaets tokens (--danger har AA-kontrast mot --surface i begge moduser). */
+.api-banner {
+ background: var(--surface);
+ border-bottom: 3px solid var(--danger);
+ color: var(--text);
+}
+.api-banner__inner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem 1.5rem;
+ padding-block: 0.9rem;
+}
+.api-banner__text {
+ flex: 1 1 20rem;
+}
+.api-banner__text p {
+ margin: 0;
+}
+.api-banner__text p + p {
+ margin-top: 0.25rem;
+ color: var(--muted);
+}
diff --git a/package-lock.json b/package-lock.json
index 4fa5f6f..391b299 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,7 +53,8 @@
"graphql": "^17.0.2",
"react": "^19.3.0",
"react-dom": "^19.3.0",
- "react-router": "^8.4.0"
+ "react-router": "^8.4.0",
+ "rxjs": "^7.8.2"
},
"devDependencies": {
"@testing-library/dom": "^10.4.2",
From c8540f189915c389f5679df114dd42012acaacfa Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:07:33 +0000
Subject: [PATCH 009/132] feat(backend): accent-insensitive search and prefix
matching for short queries (#15)
Normalized title columns use an IMMUTABLE unaccent wrapper. One- and two-character queries become prefix matches ranked by popularity.
---
backend/migrations/004_unaccent_search.sql | 33 +++++
backend/src/search.ts | 44 ++++--
backend/src/typeDefs.ts | 3 +-
backend/test/search-index.test.ts | 90 +++++++++++++
backend/test/search.test.ts | 149 +++++++++++++++++++--
deploy/setup-vm.sh | 5 +-
docs/deploy.md | 6 +-
docs/oppsett.md | 10 +-
8 files changed, 311 insertions(+), 29 deletions(-)
create mode 100644 backend/migrations/004_unaccent_search.sql
create mode 100644 backend/test/search-index.test.ts
diff --git a/backend/migrations/004_unaccent_search.sql b/backend/migrations/004_unaccent_search.sql
new file mode 100644
index 0000000..d8efe8c
--- /dev/null
+++ b/backend/migrations/004_unaccent_search.sql
@@ -0,0 +1,33 @@
+-- Aksentuavhengig søk («amelie» finner «Amélie») og prefiksindekser for korte søk (1–2 tegn).
+
+CREATE EXTENSION IF NOT EXISTS unaccent;
+
+-- unaccent() er bare STABLE (ordlisten kan byttes), og Postgres krever IMMUTABLE i indeksuttrykk og
+-- genererte kolonner. Standardgrepet er en IMMUTABLE innpakning som peker på ordlisten med fullt
+-- kvalifisert navn, slik at den ikke avhenger av search_path. Ordlisten endres i praksis aldri;
+-- skulle den gjøre det, må kolonnene og indeksene bygges på nytt. STRICT lar planleggeren folde kall
+-- med konstant argument (søketeksten), slik at indeksene kan brukes.
+CREATE FUNCTION f_unaccent(text) RETURNS text
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
+ AS $$ SELECT public.unaccent('public.unaccent', $1) $$;
+
+-- Normalisert tittel (små bokstaver, uten aksenter) som lagrede, genererte kolonner. Et rent
+-- indeksuttrykk `lower(f_unaccent(primary_title))` ville også virket, men da regnes unaccent ut
+-- på nytt for hver rad som leses i recheck, filter og relevansberegning (målt: ca. 2x tregere på
+-- brede søk som «the»; se docs/ytelse.md). Prisen er ca. 2 x tittellengde ekstra lagring per rad.
+ALTER TABLE titles
+ ADD COLUMN primary_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(primary_title))) STORED,
+ ADD COLUMN original_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(original_title))) STORED;
+
+-- Trigramindeksene byttes til de normaliserte kolonnene (se src/search.ts).
+DROP INDEX titles_primary_title_trgm_idx;
+DROP INDEX titles_original_title_trgm_idx;
+CREATE INDEX titles_primary_title_trgm_idx
+ ON titles USING gin (primary_title_norm gin_trgm_ops);
+CREATE INDEX titles_original_title_trgm_idx
+ ON titles USING gin (original_title_norm gin_trgm_ops);
+
+-- Søk på 1–2 tegn ble tidligere delstrengsøk uten bruk av indeks. De gjøres nå om til prefiksmatch
+-- (LIKE 'a%'), som en btree med text_pattern_ops kan svare på uavhengig av collation.
+CREATE INDEX titles_primary_title_prefix_idx ON titles (primary_title_norm text_pattern_ops);
+CREATE INDEX titles_original_title_prefix_idx ON titles (original_title_norm text_pattern_ops);
diff --git a/backend/src/search.ts b/backend/src/search.ts
index 3d1338d..641c008 100644
--- a/backend/src/search.ts
+++ b/backend/src/search.ts
@@ -49,6 +49,25 @@ export function escapeLike(text: string): string {
return text.replace(/[\\%_]/g, (c) => `\\${c}`);
}
+/**
+ * Normalisert tittel (små bokstaver, uten aksenter) ligger som genererte kolonner, med indekser på
+ * dem (004_unaccent_search.sql). Søketeksten må normaliseres med nøyaktig samme uttrykk, og det
+ * gjøres i Postgres (`norm`) for at TypeScript og databasen aldri skal uenes om hva «uten aksent»
+ * betyr. Kolonnene i stedet for et uttrykk i spørringen unngår at unaccent() kjøres på nytt for
+ * hver rad i filter, recheck og relevansberegning.
+ */
+const norm = (expr: string) => `lower(f_unaccent(${expr}))`;
+const TITLE_NORM = 't.primary_title_norm';
+const ORIGINAL_NORM = 't.original_title_norm';
+
+/**
+ * Søk på 1–2 tegn har ingen trigrammer, så GIN-indeksene kan ikke brukes og delstrengsøk ville
+ * lest hele tabellen og regnet likhet på nesten alle rader (ca. 0,5 s på 120 000 titler). Slike
+ * søk er derfor prefiksmatch med btree-indeks og rangeres etter popularitet (se docs/ytelse.md).
+ */
+export const SHORT_QUERY_MAX = 2;
+export const isShortQuery = (query: string): boolean => [...query].length <= SHORT_QUERY_MAX;
+
export type Dimension = 'genres' | 'decades' | 'types' | 'available';
// Ett PK-oppslag i title_streams per kandidatrad (semi-join), aldri en skanning av tabellen.
@@ -66,11 +85,11 @@ function filterConditions(
): string[] {
const conds: string[] = [];
if (query) {
- // lower() gjøres i Postgres på begge sider, så samme funksjon brukes som i indeksen.
- const like = p.add(`%${escapeLike(query)}%`);
- conds.push(
- `(lower(t.primary_title) LIKE lower(${like}) OR lower(t.original_title) LIKE lower(${like}))`,
- );
+ // Normaliseringen gjøres i Postgres på begge sider, så samme uttrykk brukes som i indeksen.
+ // Prefiks for korte søk (btree), delstreng ellers (trigram-GIN).
+ const pattern = isShortQuery(query) ? `${escapeLike(query)}%` : `%${escapeLike(query)}%`;
+ const like = norm(p.add(pattern));
+ conds.push(`(${TITLE_NORM} LIKE ${like} OR ${ORIGINAL_NORM} LIKE ${like})`);
}
if (skip !== 'genres' && filters.genres.length > 0) {
// @> = «inneholder alle»; bruker GIN-indeksen på titles.genres.
@@ -112,14 +131,16 @@ const ID: KeyDef = { expr: 't.id', cast: 'text', kind: 'text' };
function sortKeys(field: SortField, query: string | null, p: Params): KeyDef[] {
switch (field) {
case 'RELEVANCE': {
- if (!query) return [VOTES, ID];
- const q = `lower(${p.add(query)}::text)`;
+ // Korte søk rangeres bare etter popularitet: likhet mot 1–2 tegn er støy, og å regne den ut
+ // for alle prefikstreffene var det dyreste ved søket.
+ if (!query || isShortQuery(query)) return [VOTES, ID];
+ const q = norm(`${p.add(query)}::text`);
// Likhet alene rangerer obskure titler med identisk navn over klassikerne brukeren som regel
// mener («Dark Knight», 1 500 stemmer, før «The Dark Knight»). word_similarity gir full score
// når søkeordene står i tittelen, likhet belønner eksakte treff, og popularitet (log10 av
// stemmer, mettet ved 10 mill.) skiller resten. Stemmer og id er tiebreak.
- const wordSim = `GREATEST(word_similarity(${q}, lower(t.primary_title)), word_similarity(${q}, lower(t.original_title)))`;
- const sim = `GREATEST(similarity(lower(t.primary_title), ${q}), similarity(lower(t.original_title), ${q}))`;
+ const wordSim = `GREATEST(word_similarity(${q}, ${TITLE_NORM}), word_similarity(${q}, ${ORIGINAL_NORM}))`;
+ const sim = `GREATEST(similarity(${TITLE_NORM}, ${q}), similarity(${ORIGINAL_NORM}, ${q}))`;
const pop = `LEAST(log(GREATEST(t.num_votes, 1)) / 7.0, 1.0)`;
const rel = `(0.6 * ${wordSim} + 0.2 * ${sim} + 0.2 * ${pop})`;
return [{ expr: rel, select: `(${rel})::float8`, cast: 'float8', kind: 'float' }, VOTES, ID];
@@ -182,7 +203,10 @@ export async function searchTitles(pool: Pool, args: SearchArgs): Promise {
+ pool = createPool(config.testDatabaseUrl);
+ await resetData(pool);
+});
+afterAll(async () => {
+ await pool.end();
+});
+
+async function planFor(query: string, field: 'RELEVANCE' | 'RATING' = 'RELEVANCE') {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ await client.query('SET LOCAL enable_seqscan = off');
+ // Utgir seg for å være poolen: hver spørring søket sender blir en EXPLAIN i stedet.
+ const plans: string[] = [];
+ const explainer = {
+ query: async (sql: string, values: unknown[]) => {
+ const res = await client.query(`EXPLAIN ${sql}`, values);
+ plans.push(res.rows.map((r) => r['QUERY PLAN']).join('\n'));
+ return { rows: [] };
+ },
+ } as unknown as Pool;
+ const conn = await searchTitles(explainer, {
+ query,
+ filters: validateFilters(null),
+ field,
+ direction: 'DESC',
+ first: 20,
+ after: null,
+ });
+ await conn.totalCount();
+ return plans.join('\n');
+ } finally {
+ await client.query('ROLLBACK');
+ client.release();
+ }
+}
+
+describe('f_unaccent', () => {
+ it('fjerner aksenter og er markert IMMUTABLE (kreves for indekser og genererte kolonner)', async () => {
+ const { rows } = await pool.query(
+ `SELECT f_unaccent('Amélie Café Ça Ångström') AS v,
+ (SELECT provolatile FROM pg_proc WHERE proname = 'f_unaccent') AS vol`,
+ );
+ expect(rows[0].v).toBe('Amelie Cafe Ca Angstrom');
+ expect(rows[0].vol).toBe('i');
+ });
+
+ it('de genererte kolonnene følger tittelen', async () => {
+ const { rows } = await pool.query(
+ "SELECT primary_title_norm AS p, original_title_norm AS o FROM titles WHERE id = 'tt0000005'",
+ );
+ expect(rows[0]).toEqual({ p: 'amelie', o: "le fabuleux destin d'amelie poulain" });
+ });
+});
+
+describe('indeksbruk', () => {
+ it.each(['dark', 'amelie', 'cafe soc'])(
+ 'delstrengsøk «%s» kan bruke trigramindeksene',
+ async (q) => {
+ const plan = await planFor(q);
+ expect(plan).toMatch(/titles_primary_title_trgm_idx/);
+ expect(plan).toMatch(/titles_original_title_trgm_idx/);
+ },
+ );
+
+ it.each(['a', 'da', 'ça'])('kort søk «%s» bruker prefiksindeksene, ikke trigram', async (q) => {
+ const plan = await planFor(q);
+ expect(plan).toMatch(/titles_primary_title_prefix_idx/);
+ expect(plan).toMatch(/titles_original_title_prefix_idx/);
+ });
+
+ it('søket regner ikke likhet for korte søk', async () => {
+ expect(await planFor('da')).not.toMatch(/similarity/);
+ expect(await planFor('dar')).toMatch(/similarity/);
+ });
+});
diff --git a/backend/test/search.test.ts b/backend/test/search.test.ts
index 17e690c..9d20426 100644
--- a/backend/test/search.test.ts
+++ b/backend/test/search.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { TITLES, TITLE_COUNT } from './fixtures.js';
import { setupApi } from './helpers.js';
-import { SEARCH } from './queries.js';
+import { collectIds, SEARCH } from './queries.js';
const env = setupApi();
@@ -43,7 +43,8 @@ describe('search: tekst', () => {
it('søker i originaltittel', async () => {
expect((await search({ query: 'fabuleux' })).titles).toEqual(['Amélie']);
- expect((await search({ query: 'Ça' })).titles).toEqual(['It']);
+ // «Ça» er et kort søk (prefiks, uten aksenter) og treffer derfor også «Café Society».
+ expect((await search({ query: 'Ça' })).titles).toEqual(['It', 'Café Society']);
});
it.each([null, '', ' ', '\t\n'])('tomt søk %j gir alle titler', async (q) => {
@@ -63,14 +64,13 @@ describe('search: tekst', () => {
});
it.each([
- ['%', ['100% Pure']],
- ['_', ['Under_score Story']],
['0% P', ['100% Pure']],
['er_sc', ['Under_score Story']],
- ["'", ['Amélie', "Don't Look Up", "O'Brien's Odyssey"]],
- ['"', ['Say "Cheese"']],
- ['\\', ['Back\\Slash']],
- ['🎬', ['Emoji 🎬 Night']],
+ ["n's O", ["O'Brien's Odyssey"]],
+ ['y "C', ['Say "Cheese"']],
+ ['Back\\', ['Back\\Slash']],
+ ['k\\S', ['Back\\Slash']],
+ ['🎬 N', ['Emoji 🎬 Night']],
['amélie', ['Amélie']],
['CAFÉ', ['Café Society']],
["Don't", ["Don't Look Up"]],
@@ -105,6 +105,139 @@ describe('search: tekst', () => {
});
});
+/** Samme normalisering som i SQL (lower + unaccent), til å utlede forventede treff fra fixturen. */
+const fold = (s: string) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
+const titleHaystack = (t: (typeof TITLES)[number]) =>
+ [t.primary, t.original ?? t.primary].map(fold);
+
+describe('search: aksentuavhengig', () => {
+ it.each([
+ ['amelie', ['Amélie']],
+ ['AMELIE', ['Amélie']],
+ ['amélie', ['Amélie']],
+ ['AMÉLIE', ['Amélie']],
+ ['amèlie', ['Amélie']],
+ ['cafe', ['Café Society']],
+ ['Cafe soc', ['Café Society']],
+ ['café', ['Café Society']],
+ ['fabuleux destin d amelie', []],
+ ["destin d'amelie", ['Amélie']],
+ ])('%j finner %j', async (q, expected) => {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ expect(r.titles).toEqual(expected);
+ expect(r.total).toBe(expected.length);
+ });
+
+ it('rangerer treff uten aksent likt som med (relevans bruker samme normalisering)', async () => {
+ const [plain, accented] = await Promise.all([
+ search({ query: 'amelie', sort: { field: 'RELEVANCE' } }),
+ search({ query: 'amélie', sort: { field: 'RELEVANCE' } }),
+ ]);
+ expect(plain.ids).toEqual(accented.ids);
+ expect(plain.titles[0]).toBe('Amélie');
+ });
+
+ it('escaping av %, _ og \\ virker sammen med aksenter', async () => {
+ expect((await search({ query: 'caf% soc' })).total).toBe(0);
+ expect((await search({ query: 'caf_ soc' })).total).toBe(0);
+ expect((await search({ query: 'back\\sl' })).titles).toEqual(['Back\\Slash']);
+ expect((await search({ query: 'bac\\sl' })).total).toBe(0);
+ expect((await search({ query: 'Back\\Slash' })).titles).toEqual(['Back\\Slash']);
+ });
+
+ it('fasettene og totalCount bruker samme normalisering', async () => {
+ const res = await env.gql(
+ 'query ($q: String) { facets(query: $q) { available types { value count } } }',
+ { q: 'cafe' },
+ );
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.facets.types).toEqual([{ value: 'MOVIE', count: 1 }]);
+ });
+});
+
+describe('search: korte søk (1–2 tegn)', () => {
+ // Prefiksmatch på normalisert primær- eller originaltittel, rangert etter stemmer og id.
+ const expectedPrefix = (q: string) =>
+ TITLES.filter((t) => titleHaystack(t).some((h) => h.startsWith(fold(q))))
+ .sort((a, b) => b.votes - a.votes || (a.id < b.id ? 1 : -1))
+ .map((t) => t.id);
+
+ it.each(['u', 'U', 'f', 'un', 'UN', 'ba', 'br', 'ca', 'ÇA', 'am', 'it', 'e'])(
+ 'prefiksmatch for %j, sortert etter popularitet',
+ async (q) => {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ expect(expectedPrefix(q).length).toBeGreaterThan(0);
+ expect(r.ids).toEqual(expectedPrefix(q));
+ expect(r.total).toBe(expectedPrefix(q).length);
+ },
+ );
+
+ it('finner ikke midt i tittelen (til forskjell fra søk på 3+ tegn)', async () => {
+ expect((await search({ query: 'ar' })).titles).not.toContain('Dark');
+ expect((await search({ query: 'ark' })).titles).toContain('Dark');
+ });
+
+ it.each(['%', '_', '\\', 'u_', '1%', '%%', '\\%', '"', "'", '🎬', '🎬🎬'])(
+ 'behandler %j som vanlig tegn og ikke jokertegn',
+ async (q) => {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ expect(r.ids).toEqual(expectedPrefix(q));
+ expect(r.total).toBe(expectedPrefix(q).length);
+ // Ingen titler starter med disse tegnene, så et jokertegn ville gitt mange treff.
+ expect(r.total).toBe(0);
+ },
+ );
+
+ it('teller emoji som ett tegn', async () => {
+ // To emoji = 2 tegn = fortsatt prefiksmatch (4 UTF-16-enheter ville ellers gitt delstrengsøk).
+ expect((await search({ query: 'E🎬' })).total).toBe(0);
+ expect((await search({ query: 'Em' })).titles).toEqual(['Emoji 🎬 Night']);
+ });
+
+ it('pagineres uten hull eller duplikater, og totalCount stemmer', async () => {
+ const all = await collectIds(env, { query: 'f' }, 5);
+ expect(all.ids).toEqual(expectedPrefix('f'));
+ expect(all.pages).toBeGreaterThan(3);
+ expect(all.total).toBe(all.ids.length);
+ });
+
+ it('kombineres med filtre og alle sorteringer', async () => {
+ const series = await search({ query: 'f', filters: { types: ['SERIES'] } });
+ const want = expectedPrefix('f').filter(
+ (id) => TITLES.find((t) => t.id === id)!.type === 'series',
+ );
+ expect(series.ids).toEqual(want);
+ for (const field of ['RATING', 'YEAR', 'TITLE']) {
+ const r = await collectIds(env, { query: 'f', sort: { field } }, 7);
+ expect([...r.ids].sort()).toEqual([...expectedPrefix('f')].sort());
+ }
+ });
+
+ it('cursor fra et kort søk kan ikke brukes på et langt søk, og omvendt', async () => {
+ const short = await env.gql(SEARCH, { query: 'f', first: 2 });
+ const long = await env.gql(SEARCH, { query: 'fil', first: 2 });
+ const shortCursor = short.data!.search.pageInfo.endCursor;
+ const longCursor = long.data!.search.pageInfo.endCursor;
+ const a = await env.gql(SEARCH, { query: 'fil', first: 2, after: shortCursor });
+ const b = await env.gql(SEARCH, { query: 'f', first: 2, after: longCursor });
+ expect(a.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ expect(b.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ });
+
+ it('grensen går mellom 2 og 3 tegn', async () => {
+ // «da» (prefiks): Dark, Dark City, Dark Waters, Darkman, Dark Knight, Don't? nei.
+ const two = await search({ query: 'da' });
+ expect(two.titles).toEqual(
+ expectedPrefix('da').map((id) => TITLES.find((t) => t.id === id)!.primary),
+ );
+ // «ark» (delstreng): treffer også titler der «ark» står inni.
+ expect((await search({ query: 'ark' })).total).toBeGreaterThan(0);
+ });
+});
+
describe('search: filtre', () => {
it('genres krever ALLE valgte sjangre', async () => {
const drama = await search({ filters: { genres: ['Drama'] } });
diff --git a/deploy/setup-vm.sh b/deploy/setup-vm.sh
index fcababd..1a62481 100755
--- a/deploy/setup-vm.sh
+++ b/deploy/setup-vm.sh
@@ -78,8 +78,9 @@ if [ -n "${TMDB_API_KEY:-}" ]; then
fi
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 \
|| sudo -u postgres createdb "$DB_NAME" --owner "$DB_USER"
-# pg_trgm krever superbruker første gang; migreringen bruker IF NOT EXISTS.
-sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'
+# pg_trgm og unaccent krever superbruker første gang (begge følger med postgresql-contrib);
+# migreringene bruker IF NOT EXISTS.
+sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent;'
step "Installerer avhengigheter og bygger"
HUSKY=0 npm ci --no-audit --no-fund
diff --git a/docs/deploy.md b/docs/deploy.md
index befbdfb..9bc50d4 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -13,11 +13,11 @@ automatisk (genererer også databasepassord i `.env`). Stegene under er det samm
## 1. PostgreSQL
```bash
-sudo apt update && sudo apt install -y postgresql postgresql-contrib # contrib gir pg_trgm
+sudo apt update && sudo apt install -y postgresql postgresql-contrib # contrib gir pg_trgm og unaccent
sudo -u postgres createuser project2 --pwprompt # velg et sterkt passord
sudo -u postgres createdb project2 --owner project2
-# pg_trgm må opprettes av en superbruker første gang (migreringen bruker IF NOT EXISTS):
-sudo -u postgres psql -d project2 -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'
+# pg_trgm og unaccent må opprettes av en superbruker første gang (migreringene bruker IF NOT EXISTS):
+sudo -u postgres psql -d project2 -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent;'
```
## 2. Kode, bygg og data
diff --git a/docs/oppsett.md b/docs/oppsett.md
index 8f7ebc3..954097d 100644
--- a/docs/oppsett.md
+++ b/docs/oppsett.md
@@ -1,6 +1,6 @@
# Oppsett
-Krever Node.js ≥ 20 (testet med 22) og PostgreSQL ≥ 14 med `pg_trgm` (følger med standard
+Krever Node.js ≥ 20 (testet med 22) og PostgreSQL ≥ 14 med `pg_trgm` og `unaccent` (begge følger med standard
`postgresql-contrib`; i Debian/Ubuntu-pakken `postgresql-contrib`, i Homebrew og offisielle
Docker-bilder er den med).
@@ -20,8 +20,8 @@ sudo -u postgres createdb project2_test
```
Bruker du en egen bruker med passord: `sudo -u postgres psql -c "CREATE USER it2810 PASSWORD 'hemmelig' SUPERUSER;"`
-(`SUPERUSER` trengs bare fordi migrasjonen kjører `CREATE EXTENSION pg_trgm`; alternativt kjør
-`CREATE EXTENSION pg_trgm;` selv som superuser i begge databasene og gi vanlig bruker eierskap.)
+(`SUPERUSER` trengs bare fordi migrasjonene kjører `CREATE EXTENSION pg_trgm` og `unaccent`; alternativt kjør
+`CREATE EXTENSION pg_trgm; CREATE EXTENSION unaccent;` selv som superuser i begge databasene og gi vanlig bruker eierskap.)
## 3. Miljøvariabler
@@ -194,7 +194,7 @@ fra scratch og setter inn et lite kontrollert datasett. De nekter å kjøre hvis
## Feilsøking
-- `permission denied to create extension "pg_trgm"`: kjør migrasjonen som superuser, eller kjør
- `CREATE EXTENSION pg_trgm;` som superuser i databasen først.
+- `permission denied to create extension "pg_trgm"` (eller `"unaccent"`): kjør migrasjonen som superuser, eller kjør
+ `CREATE EXTENSION pg_trgm; CREATE EXTENSION unaccent;` som superuser i databasen først.
- `Fant ikke datafilene`: gjør steg 5A eller 5B.
- `password authentication failed`: sett riktig bruker/passord i `DATABASE_URL` / `TEST_DATABASE_URL`.
From 8c8d6bc8c3a5d99c2d7fb098462492278c24d629 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:07:36 +0000
Subject: [PATCH 010/132] docs: document search normalization, short queries
and measurements (#15)
---
docs/api.md | 3 +-
docs/beslutninger.md | 26 ++++++++++--
docs/status.md | 6 ++-
docs/ytelse.md | 94 ++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 119 insertions(+), 10 deletions(-)
diff --git a/docs/api.md b/docs/api.md
index d18612a..7e3c1a0 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -134,7 +134,8 @@ type DeleteReviewPayload {
}
type Query {
- # query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel.
+ # query: tom/null = alle titler. Case- og aksentuavhengig delstrengsøk («amelie» finner «Amélie»)
+ # i primær- og originaltittel. Søk på 1–2 tegn er prefiksmatch og rangeres etter popularitet.
search(
query: String
filters: SearchFilters
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
index 1f49401..331c5b2 100644
--- a/docs/beslutninger.md
+++ b/docs/beslutninger.md
@@ -23,10 +23,9 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
enklere og raskere uten join, og sjangre til en hel resultatside følger med raden (ingen N+1).
`start_decade` er en generert kolonne slik at tiårfilter er en likhet på en indeksert kolonne.
Prisen er at importen skriver sjangre to steder (i samme transaksjon).
-- **Søk med `lower(col) LIKE lower('%…%')` og trigram-GIN på `lower(...)`-uttrykk,** ikke `ILIKE`.
- Indeksuttrykket må være identisk med spørringen for å bli brukt. Brukerens `%`, `_` og `\` escapes
- slik at de er vanlige tegn. Aksenter foldes ikke (`cafe` finner ikke `Café`); `unaccent`
- er ikke tatt med for å slippe en ekstra utvidelse.
+- **Søk med `LIKE` mot normaliserte tittelkolonner og trigram-GIN,** ikke `ILIKE`. Kolonnene
+ `primary_title_norm`/`original_title_norm` er `lower(f_unaccent(tittel))` (se «Aksentuavhengig søk»
+ under), og indeksene ligger på dem. Brukerens `%`, `_` og `\` escapes slik at de er vanlige tegn.
- **Keyset-paginering med radsammenligning.** Alle sorteringsnøkler går i samme retning
(`(a, b, id) < ($1,$2,$3)`), siste nøkkel er `id` (unik), og nullable kolonner erstattes av
sentinel via `COALESCE` (rating → -1, år → 0). Det gir stabil paginering uten hull/duplikater og lar
@@ -200,3 +199,22 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
å evicte `Review:`. Ugyldig id (ikke 1–18 siffer) gir `BAD_USER_INPUT`.
- **Tittel-id valideres som `^tt\d{7,10}$`** (`isPlausibleTitleId`). Fixture, testdata og
Archive-mock bruker bare 7 siffer; IMDb har 7–8 i dag, 10 gir slingringsmonn.
+- **Aksentuavhengig søk («Aksentuavhengig søk»).** `unaccent` via en IMMUTABLE innpakning
+ `f_unaccent(text)` (migrering 004). Normalisert tittel lagres som genererte kolonner
+ `primary_title_norm`/`original_title_norm` (`lower(f_unaccent(tittel))`) med trigram-GIN på dem, i
+ stedet for å legge uttrykket i indeksen og i hver spørring. Grunnen er målt: uttrykket kjører
+ `unaccent` på nytt for hver rad i recheck, filter og relevans og ga opptil 2x tregere brede søk
+ (tall i `docs/ytelse.md`). Kolonnene koster ca. 5 MB ekstra på 120 000 titler. Innpakningen peker på
+ ordlisten med fullt kvalifisert navn; endres ordlisten må kolonner og indekser bygges på nytt.
+ `unaccent` følger med `postgresql-contrib` sammen med `pg_trgm` og er «trusted» fra PostgreSQL 13, så
+ `deploy/setup-vm.sh` og `docs/oppsett.md` trenger bare å nevne den ved siden av `pg_trgm`.
+ Konsekvens: «ø», «å» og «æ» foldes til «o», «a» og «ae».
+- **Korte søk (1–2 tegn) er prefiksmatch og rangeres etter popularitet.** Delstrengsøk på 1–2 tegn
+ har ingen trigrammer og traff opptil 70 % av tabellen, og relevansberegningen (`similarity`) på
+ alle treff tok ca. 0,5 s for «a». Prefiksmatch mot de normaliserte kolonnene bruker
+ `text_pattern_ops`-btree (eller stemmeindeksen baklengs), og relevans er stemmer, så id. Det
+ endrer funksjonen: «ar» finner ikke lenger «Dark». Alternativene vi vurderte: (1) beholde delstreng
+ og bare droppe likhetsberegningen (hindrer ikke full skanning), (2) ordprefiks («ar» finner
+ «The Arrival»), som krever tokenisering eller regex uten indeks. Valgte enkel prefiksmatch fordi
+ den er rask, forutsigbar og dekker det brukere gjør når de skriver de første bokstavene av en
+ tittel. Cursoren har en egen signatur for korte søk, siden sorteringsnøklene er annerledes.
diff --git a/docs/status.md b/docs/status.md
index bae0374..a3b56ea 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -37,7 +37,9 @@ Alle punktene i `PLAN.md` er gjort og verifisert i utviklingsmiljøet.
## Kjente begrensninger
-- Søk folder ikke aksenter: «cafe» finner ikke «Café». Store og små bokstaver er dekket.
-- Søk på 1–2 tegn kan ikke bruke trigram-indeksen og er tregere (se `docs/ytelse.md`).
+- Søk på 1–2 tegn er prefiksmatch på tittelen («th» finner «The Matrix», men ikke «Death»), og
+ rangeres etter popularitet. Fra 3 tegn er det delstrengsøk med likhetsrangering.
+- Aksentfolding følger Postgres' `unaccent`: «ø» blir «o» og «å» blir «a», så «bla» finner også «Blå».
+- `totalCount` for svært vanlige søk («a», «the») tar 20–50 ms fordi alle treff må telles.
- Anonym bruker-ID ligger i `localStorage`. Anmeldelser og liste følger nettleseren, ikke en
innlogget bruker.
diff --git a/docs/ytelse.md b/docs/ytelse.md
index ef575e4..9a1ae85 100644
--- a/docs/ytelse.md
+++ b/docs/ytelse.md
@@ -42,9 +42,8 @@ for å avgjøre `hasNextPage`).
- **Relevanssortering** må beregne `similarity()` for alle treff før den kan sortere, så den er dyrere
(8,9 ms) enn sortering på lagret kolonne, men trigram-indeksen holder antall rader nede
(1 774 av 120 000). Ved svært vanlige søkeord («the») er dette den tregeste spørringen.
-- **Grenser vi kjenner til:** søketekst på 1–2 tegn har færre enn ett trigram og kan ikke bruke
- GIN-indeksen (Postgres faller tilbake til sekvensiell skanning, ca. samme tid som «uten indeks»).
- `totalCount` og fasetter regnes ut på hele treffmengden hver gang og er derfor den dyreste delen
+- **Grenser vi kjenner til:** søketekst på 1–2 tegn har ingen trigrammer for delstrengsøk. De løses
+ som prefiksmatch (se «Aksentfolding og korte søk» under). `totalCount` og fasetter regnes ut på hele treffmengden hver gang og er derfor den dyreste delen
av et bredt søk; `totalCount` beregnes bare når klienten ber om feltet.
### Rådata (utdrag)
@@ -79,6 +78,95 @@ Limit (actual time=8.910..8.915 rows=21 loops=1)
Execution Time: 8.949 ms
```
+## Aksentfolding og korte søk (issue #15)
+
+Migrering `004_unaccent_search.sql` gjør søket aksentuavhengig og endrer 1–2 tegns søk til prefiksmatch.
+Målt på samme syntetiske datasett (120 000 titler, PostgreSQL 16.15, lokal maskin, `jit = off`,
+`VACUUM ANALYZE` før måling). Hver tid er median av 7 kjøringer av `EXPLAIN (ANALYZE)` på spørringene
+`searchTitles` faktisk bygger (`LIMIT 21`), én gang for siden og én gang for `totalCount`.
+«Før» er koden og indeksene fra før migreringen, kopiert til en egen database.
+
+> **Merk:** Maskinen delte CPU med andre prosesser under målingen, så tall under ca. 5 ms og alle
+> forskjeller på under ca. 20 % bør leses som «omtrent lik». Ordenene (størrelsesforholdene) er
+> reproduserbare på tvers av kjøringer; absolutt-tidene er det ikke. Datasettet er syntetisk, så
+> hvor mange titler som begynner på «a» eller «th» er ikke det samme som i ekte IMDb-data.
+
+| Søk (sortering) | Side før (ms) | Side etter (ms) | `totalCount` før (ms) | `totalCount` etter (ms) |
+| ----------------------- | -------------: | --------------: | --------------------: | ----------------------: |
+| «a» (relevans), 1 tegn | 462,6 | 0,19 | 41,4 | 12,3 |
+| «a» (rating) | 0,07 | 0,28 | 46,3 | 19,3 |
+| «ma» (relevans), 2 tegn | 81,9 | 0,64 | 81,8 | 8,9 |
+| «ma» (rating) | 0,4 | 0,66 | 83,4 | 7,9 |
+| «br» (relevans), 2 tegn | 82,4 | 0,75 | 88,0 | 7,2 |
+| «th» (relevans), 2 tegn | 351,0 | 0,034 | 77,8 | 24,6 |
+| «the» (relevans) | 237,1 | 201,8 | 45,5 | 20,8 |
+| «dark» (relevans) | 20,7 | 17,2 | 2,9 | 2,8 |
+| «dark» (rating) | 1,9 | 1,1 | 2,9 | 2,1 |
+| «amelie» (relevans) | 0,06 (0 treff) | 17,6 | 0,05 (0 treff) | 3,1 |
+
+Rader med «rating» som sortering var allerede raske før, fordi planleggeren kunne gå baklengs på
+ratingindeksen og stoppe etter 21 treff; for dem er det bare `totalCount` som forbedres. For «amelie» ga
+søket 0 treff før, fordi aksenten ikke ble foldet; tallene er derfor ikke sammenlignbare.
+
+### Hva som ble gjort og hvorfor
+
+- **Aksentfolding:** `unaccent` via en IMMUTABLE innpakning `f_unaccent(text)` (Postgres godtar ikke
+ den STABLE `unaccent()` i indekser). Normalisert tittel (`lower(f_unaccent(tittel))`) lagres som
+ genererte kolonner `primary_title_norm` og `original_title_norm`, og trigram-GIN-indeksene ligger
+ på dem. Søketeksten normaliseres med samme uttrykk i SQL.
+- **Hvorfor kolonner og ikke et indeksuttrykk:** vi prøvde først ren `lower(f_unaccent(col))` som
+ indeksuttrykk i spørringen (slik `lower(col)` var før). Indeksene ble brukt, men `unaccent` kjøres
+ da på nytt for hver rad som leses i recheck, filter og relevansberegning. Mot 120 000 titler ga det
+ en tydelig regresjon på brede søk: «the» (relevans) 237 → ca. 400 ms og «dark» (rating) 1,9 → 6,5 ms.
+ Med genererte kolonner er alle tallene på høyde med eller bedre enn før. Prisen er plass: heapen
+ vokste fra 19 til 24 MB, og de to nye prefiksindeksene tar 3,2 MB. Migreringen tok ca. 5 s.
+- **Korte søk (1–2 tegn):** prefiksmatch (`LIKE 'a%'`) mot de normaliserte kolonnene, med btree
+ `text_pattern_ops`-indekser, og relevans = popularitet (stemmer, så id) i stedet for likhet.
+ Før lå kostnaden i å regne `similarity`/`word_similarity` på nesten alle rader (462 ms for «a»);
+ nå leser planleggeren de 21 første radene baklengs fra stemmeindeksen (`Index Scan Backward using
+titles_votes_idx`, 0,2–0,8 ms) eller bruker prefiksindeksene når treffene er få (søk som ikke treffer
+ noe: `BitmapOr` av `titles_primary_title_prefix_idx` og `titles_original_title_prefix_idx`).
+
+Plan etter, «a» (relevans), siden:
+
+```
+Limit (actual time=0.012..0.126 rows=21 loops=1)
+ -> Index Scan Backward using titles_votes_idx on titles t (actual time=0.011..0.124 rows=21 loops=1)
+ Filter: ((primary_title_norm ~~ 'a%'::text) OR (original_title_norm ~~ 'a%'::text))
+ Rows Removed by Filter: 166
+```
+
+Plan etter, et 2-tegns søk med få treff, f.eks. «mo» (relevans):
+
+```
+Sort Key: num_votes DESC, id DESC
+-> Bitmap Heap Scan on titles t
+ Recheck Cond: ((primary_title_norm ~~ 'mo%'::text) OR (original_title_norm ~~ 'mo%'::text))
+ -> BitmapOr
+ -> Bitmap Index Scan on titles_primary_title_prefix_idx
+ -> Bitmap Index Scan on titles_original_title_prefix_idx
+```
+
+Plan etter, «amelie» (relevans): `BitmapOr` av `titles_primary_title_trgm_idx` og
+`titles_original_title_trgm_idx` med `Index Cond: (primary_title_norm ~~ '%amelie%'::text)`, altså at
+søketeksten uten aksent bruker trigramindeksen på de normaliserte kolonnene.
+
+### Ærlige merknader
+
+- **«The» og andre svært vanlige ord er fortsatt tregt (ca. 200 ms for relevanssortering).** Ca.
+ 35 000 av 120 000 titler inneholder «the», så planleggeren velger
+ `Parallel Seq Scan` og regner likhet for alle treff. Dette er uendret fra før og ikke løst av
+ denne migreringen; en mulig forbedring er å rangere vanlige ord etter popularitet først.
+- **`totalCount` for «a», «th» og «the» koster 20–50 ms** fordi alle treff må telles. Det er bare
+ kostnad når klienten ber om feltet.
+- **Prefiksmatch er en funksjonell endring:** «th» finner «The Matrix» men ikke «Death», og «ar» finner
+ ikke «Dark». Fra 3 tegn er det delstrengsøk som før. Vi valgte det fordi delstrengsøk på 1–2 tegn
+ matcher en stor del av tabellen uansett (ca. 82 500 av 120 000 titler inneholder «a», mot 11 200 som
+ begynner på «a»), så det gir ikke presise treff.
+- **`unaccent` foldes også norske tegn:** «ø» blir «o», «å» blir «a» og «æ» blir «ae». Det er bra for
+ «Amélie» og «Café», men betyr at «bla» også finner «Blå».
+- Absolutt-tidene gjelder datasettet over; ekte IMDb-data har flere titler og andre ordfordelinger.
+
## Importtid
`npm run db:seed -w backend` importerer 120 000 syntetiske titler (162 000 linjer i `basics`) på ca.
From 4bf62884fd3e20603e5778b10a9ee61ca53d3009 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:15:42 +0000
Subject: [PATCH 011/132] fix: address review of API-unavailable banner (#13)
Use Wants instead of Requires for postgresql.service. Map database connection errors to SERVICE_UNAVAILABLE and treat that code as downtime in the frontend. Banner moves focus to main after a successful retry and reports failed retries in a status region. Add pool connection timeout and strengthen the E2E retry test.
---
backend/src/app.ts | 2 +
backend/src/db.ts | 3 ++
backend/src/dbErrors.test.ts | 35 ++++++++++++++
backend/src/dbErrors.ts | 46 +++++++++++++++++++
backend/test/server.test.ts | 14 ++++++
deploy/project2-backend.service | 6 +--
docs/beslutninger.md | 2 +-
docs/deploy.md | 2 +-
docs/ki-logg.md | 2 +-
e2e/api-unavailable.spec.ts | 12 ++++-
frontend/src/apollo/apiStatus.ts | 7 ++-
frontend/src/apollo/client.test.ts | 13 ++++++
.../components/ApiUnavailableBanner.test.tsx | 25 +++++++++-
.../src/components/ApiUnavailableBanner.tsx | 17 +++++--
frontend/src/styles/global.css | 5 ++
15 files changed, 179 insertions(+), 12 deletions(-)
create mode 100644 backend/src/dbErrors.test.ts
create mode 100644 backend/src/dbErrors.ts
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 547ce9c..8c7dd18 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -4,6 +4,7 @@ import { ArtworkService } from './artwork.js';
import { config } from './config.js';
import { createContext, type Context } from './context.js';
import type { Pool } from './db.js';
+import { isDatabaseUnavailable, serviceUnavailableError } from './dbErrors.js';
import { depthLimit } from './depthLimit.js';
import { schema } from './schema.js';
@@ -126,6 +127,7 @@ export function createApp({
if (http?.status && http.status < 500) {
return error as GraphQLError;
}
+ if (isDatabaseUnavailable(error)) return serviceUnavailableError();
if (process.env.NODE_ENV !== 'test') console.error('Uventet feil:', error);
return new GraphQLError('Intern feil.', { extensions: { code: 'INTERNAL_SERVER_ERROR' } });
},
diff --git a/backend/src/db.ts b/backend/src/db.ts
index ac8b063..648f823 100644
--- a/backend/src/db.ts
+++ b/backend/src/db.ts
@@ -8,6 +8,9 @@ export function createPool(connectionString: string): pg.Pool {
const pool = new pg.Pool({
connectionString,
max: 10,
+ // Uten tidsgrense henger en tilkobling mot en database bak brannmur til OS gir opp, og
+ // /health og spørringer fyller poolen mens de venter.
+ connectionTimeoutMillis: 5_000,
statement_timeout: 15_000,
});
// Uten en error-lytter krasjer Node hvis en ledig tilkobling brytes (f.eks. Postgres restarter).
diff --git a/backend/src/dbErrors.test.ts b/backend/src/dbErrors.test.ts
new file mode 100644
index 0000000..bd15593
--- /dev/null
+++ b/backend/src/dbErrors.test.ts
@@ -0,0 +1,35 @@
+import { GraphQLError } from 'graphql';
+import { describe, expect, it } from 'vitest';
+import { isDatabaseUnavailable } from './dbErrors.js';
+
+const withCode = (code: string) => Object.assign(new Error('x'), { code });
+
+describe('isDatabaseUnavailable', () => {
+ it.each(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', '57P01', '57P03', '08006', '08001'])(
+ 'regner %s som nedetid',
+ (code) => {
+ expect(isDatabaseUnavailable(withCode(code))).toBe(true);
+ },
+ );
+
+ it('kjenner igjen pg-pools tilkoblingstimeout og AggregateError', () => {
+ expect(isDatabaseUnavailable(new Error('timeout exceeded when trying to connect'))).toBe(true);
+ const agg = Object.assign(new Error(''), {
+ errors: [withCode('ECONNREFUSED'), withCode('ECONNREFUSED')],
+ });
+ expect(isDatabaseUnavailable(agg)).toBe(true);
+ });
+
+ it('pakker ut originalError fra GraphQLError', () => {
+ const wrapped = new GraphQLError('x', { originalError: withCode('ECONNREFUSED') });
+ expect(isDatabaseUnavailable(wrapped)).toBe(true);
+ });
+
+ it('regner ikke vanlige feil, SQL-feil eller ikke-feil som nedetid', () => {
+ expect(isDatabaseUnavailable(new Error('boom'))).toBe(false);
+ expect(isDatabaseUnavailable(withCode('42P01'))).toBe(false);
+ expect(isDatabaseUnavailable(withCode('57014'))).toBe(false);
+ expect(isDatabaseUnavailable(null)).toBe(false);
+ expect(isDatabaseUnavailable('ECONNREFUSED')).toBe(false);
+ });
+});
diff --git a/backend/src/dbErrors.ts b/backend/src/dbErrors.ts
new file mode 100644
index 0000000..daab2cb
--- /dev/null
+++ b/backend/src/dbErrors.ts
@@ -0,0 +1,46 @@
+import { GraphQLError } from 'graphql';
+
+const CONNECTION_CODES = new Set([
+ 'ECONNREFUSED',
+ 'ENOTFOUND',
+ 'ETIMEDOUT',
+ 'ECONNRESET',
+ 'EAI_AGAIN',
+]);
+
+/**
+ * Er dette en feil fordi databasen ikke kan nås (i motsetning til en bug i en spørring)?
+ * Klient og helsesjekk skal skille de to: nedetid forklares med ett banner, bugs vises som feil.
+ */
+export function isDatabaseUnavailable(error: unknown): boolean {
+ // Resolverfeil kommer pakket i GraphQLError; pg-feilen ligger i originalError.
+ const err = ((error as GraphQLError)?.originalError ?? error) as {
+ code?: unknown;
+ message?: unknown;
+ errors?: unknown[];
+ } | null;
+ if (!err || typeof err !== 'object') return false;
+ const code = typeof err.code === 'string' ? err.code : '';
+ // 57P01/57P03: Postgres stenger ned eller starter opp. Klasse 08: tilkoblingsfeil.
+ if (CONNECTION_CODES.has(code) || code === '57P01' || code === '57P03' || code.startsWith('08')) {
+ return true;
+ }
+ // pg-pool gir en vanlig Error uten kode når connectionTimeoutMillis utløper.
+ if (
+ typeof err.message === 'string' &&
+ /timeout exceeded when trying to connect/i.test(err.message)
+ ) {
+ return true;
+ }
+ // Node 20+ pakker flere adresseforsøk (IPv4 og IPv6) i en AggregateError.
+ return (
+ Array.isArray(err.errors) && err.errors.length > 0 && err.errors.every(isDatabaseUnavailable)
+ );
+}
+
+/** Generisk melding og kode uten SQL, vertsnavn eller stack. */
+export function serviceUnavailableError(): GraphQLError {
+ return new GraphQLError('Tjenesten er midlertidig utilgjengelig.', {
+ extensions: { code: 'SERVICE_UNAVAILABLE' },
+ });
+}
diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts
index 4cc5f0b..52f9cf6 100644
--- a/backend/test/server.test.ts
+++ b/backend/test/server.test.ts
@@ -44,6 +44,20 @@ describe('GET /health', () => {
});
});
+describe('database nede mens Node kjører', () => {
+ it('gir SERVICE_UNAVAILABLE uten interne detaljer', async () => {
+ const dead = createPool('postgres://postgres:x@127.0.0.1:1/none');
+ try {
+ const res = await post(createApp({ pool: dead }), '{ genres }');
+ const body = await res.json();
+ expect(body.errors[0].extensions.code).toBe('SERVICE_UNAVAILABLE');
+ expect(JSON.stringify(body)).not.toMatch(/127\.0\.0\.1|ECONNREFUSED|postgres/);
+ } finally {
+ await dead.end();
+ }
+ });
+});
+
describe('servergrenser', () => {
it('slår av introspeksjon i produksjon, men ikke i utvikling', async () => {
const dev = createApp({ pool, production: false });
diff --git a/deploy/project2-backend.service b/deploy/project2-backend.service
index 4270903..80b5e0c 100644
--- a/deploy/project2-backend.service
+++ b/deploy/project2-backend.service
@@ -5,9 +5,9 @@
Description=IT2810 gruppe 17 prosjekt 2 – GraphQL-backend
# Backenden er ubrukelig uten databasen, så vent på at Postgres er oppe.
After=network.target postgresql.service
-# Requires (ikke bare Wants): stopper Postgres, stoppes backenden også og startes igjen sammen med den,
-# i stedet for å stå oppe og svare 500 på alt.
-Requires=postgresql.service
+# Wants (ikke Requires): Requires propagerer bare stopp, ikke start, og postgresql.service er på Ubuntu en
+# oneshot-wrapper. Restart=always under holder backenden oppe og får den tilbake når databasen er klar.
+Wants=postgresql.service
[Service]
Type=simple
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
index 160f79f..2b3f3c5 100644
--- a/docs/beslutninger.md
+++ b/docs/beslutninger.md
@@ -175,4 +175,4 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
- **Ingen mock-data i produksjon; forklarende banner i stedet.** Sensor og brukere skal se ekte data fra databasen eller en ærlig feil. Mock-data ville skjult at serveren er utilgjengelig (typisk: utenfor NTNU-nettet uten VPN) og at appen ikke viser det den utgir seg for. Mock-servere finnes allerede for utvikling og E2E (`tmdb:mock`, `archive:mock`).
- **Én global tilstand, ikke én feil per rad.** Error-linken i `apollo/client.ts` setter reactive var `apiUnavailable` ved nettverksfeil (fetch feilet, HTTP 502/503/504, ikke-JSON-svar) og nullstiller den ved første svar fra serveren. GraphQL-feil teller ikke: da svarte serveren, og feilen er vår bug eller brukerinput. `ApiUnavailableBanner` i `Layout` kunngjør nedetiden én gang (`role="alert"`, knappen ligger utenfor alert-regionen) og forklarer NTNU VPN. Radene viser stille skjelett mens banneret står; andre feil viser fortsatt per-rad-feil.
- **`rxjs` er lagt til som direkte avhengighet i `frontend`.** Apollo 4 har den som obligatorisk peer-avhengighet (allerede i bundlen); vi bruker bare `tap` i linkkjeden.
-- **`GET /health` gjør `SELECT 1` med 2 s tidsgrense** (200 `ok`, 503 `db-unavailable`). Yogas egen `/health` (alltid «alive») er flyttet, fordi den ikke sier noe om databasen. Systemd-enheten har `Restart=always` og `Requires=postgresql.service`.
+- **`GET /health` gjør `SELECT 1` med 2 s tidsgrense** (200 `ok`, 503 `db-unavailable`). Yogas egen `/health` (alltid «alive») er flyttet, fordi den ikke sier noe om databasen. Systemd-enheten har `Restart=always` og `Wants=postgresql.service` (+ `After`).
diff --git a/docs/deploy.md b/docs/deploy.md
index 354e4bb..a0970e6 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -51,7 +51,7 @@ curl -s -X POST localhost:3001/graphql -H 'content-type: application/json' \
Helsesjekk: `curl -s localhost:3001/health` gir `{"status":"ok"}` (200), eller `{"status":"db-unavailable"}` (503)
hvis Postgres ikke svarer. Apache videresender den som `/project2/health`; `bash deploy/sjekk.sh` kjører begge.
-Enheten har `Restart=always` og `Requires=postgresql.service`. Frontend viser et banner (NTNU VPN) hvis API-et ikke nås.
+Enheten har `Restart=always` og `Wants=postgresql.service` (+ `After`). Frontend viser et banner (NTNU VPN) hvis API-et ikke nås.
## 4. Apache
diff --git a/docs/ki-logg.md b/docs/ki-logg.md
index 1459843..72f2a24 100644
--- a/docs/ki-logg.md
+++ b/docs/ki-logg.md
@@ -15,4 +15,4 @@
- 2026-09-30 – TMDB-plakater (backend) og kinoaktig UI (frontend) – backend-utvikler + frontend-utvikler – 209 backend-, 111 komponent- og 48 E2E-tester grønne mot ekte backend og falsk TMDB.
- 2026-09-30 – README.md med funksjonalitet, arkitektur, testing, a11y, bærekraft, KI-bruk og VM-lenke – leder – på forespørsel fra Nicolay.
- 2026-09-30 – M6 lovlig strømming fra Internet Archive: import, filter, «Se gratis nå», videospiller – backend-utvikler + frontend-utvikler – 259 backend-, 167 komponent- og 69 E2E-tester grønne (én E2E-test var ustabil én gang og besto ved omkjøring, reproduserte ikke i to nye kjøringer).
-- 2026-10-06 – #13 Tydelig feiltilstand når API-et ikke nås: global banner, error link, `/health`, deploy-oppdateringer – frontend-utvikler – komponent- og backend-tester grønne.
+- 2026-10-06 – #13 Tydelig feiltilstand når API-et ikke nås: global banner, error link, `/health`, deploy-oppdateringer – frontend-utvikler – 272 backend-, 186 komponent- og 76 av 77 E2E-tester grønne (den ene er den kjente ustabile watch.spec.ts:40, som også feiler på base).
diff --git a/e2e/api-unavailable.spec.ts b/e2e/api-unavailable.spec.ts
index b8a0abe..51292a5 100644
--- a/e2e/api-unavailable.spec.ts
+++ b/e2e/api-unavailable.spec.ts
@@ -28,11 +28,21 @@ test('nedetid gir ett banner, og «Prøv igjen» henter data når API-et er tilb
});
test('banneret blir stående når API-et fortsatt er nede etter «Prøv igjen»', async ({ page }) => {
- await page.route(GRAPHQL, (route) => route.abort());
+ let aborted = 0;
+ await page.route(GRAPHQL, (route) => {
+ aborted++;
+ return route.abort();
+ });
await page.goto('./');
await expect(page.getByRole('alert')).toHaveCount(1);
+ const before = aborted;
await page.getByRole('button', { name: /Prøv igjen|Prøver/ }).click();
+ // Et nytt forsøk skal faktisk gå ut til nettverket, og brukeren får beskjed om at det feilet.
+ await expect.poll(() => aborted).toBeGreaterThan(before);
+ await expect(
+ page.getByRole('status').filter({ hasText: 'Fortsatt ingen kontakt' }),
+ ).toBeVisible();
await expect(page.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled();
await expect(page.getByRole('alert')).toHaveCount(1);
});
diff --git a/frontend/src/apollo/apiStatus.ts b/frontend/src/apollo/apiStatus.ts
index dc04958..738ea43 100644
--- a/frontend/src/apollo/apiStatus.ts
+++ b/frontend/src/apollo/apiStatus.ts
@@ -6,6 +6,8 @@ import { CombinedGraphQLErrors, ServerError, ServerParseError } from '@apollo/cl
* Én felles «API utilgjengelig»-tilstand. Uten den viser hver rad og hvert søk sin egen feil
* når serveren ikke svarer, og brukeren får aldri vite hvorfor.
*/
+// Banneret følger siste svar: ett vellykket svar beviser at API-et nås og nullstiller det, så en enkelt
+// treg spørring som gir 504 kan blinke banneret kort. Vi aksepterer det i stedet for å skjule ekte nedetid.
export const apiUnavailable = makeVar(false);
export const useApiUnavailable = () => useReactiveVar(apiUnavailable);
@@ -18,7 +20,10 @@ const GATEWAY_STATUSES = new Set([502, 503, 504]);
* er vår bug eller brukerens input; da ville et «ingen kontakt»-banner være feil og villedende.
*/
export function isNetworkDown(error: unknown): boolean {
- if (CombinedGraphQLErrors.is(error)) return false;
+ if (CombinedGraphQLErrors.is(error)) {
+ // Backenden svarer HTTP 200 med denne koden når Node lever, men databasen ikke gjør det.
+ return error.errors.some((e) => e.extensions?.code === 'SERVICE_UNAVAILABLE');
+ }
if (ServerError.is(error)) return GATEWAY_STATUSES.has(error.statusCode);
// Ikke-JSON-svar (f.eks. HTML-feilside fra proxyen eller en innloggingsside) betyr at API-et ikke nås.
if (ServerParseError.is(error)) return true;
diff --git a/frontend/src/apollo/client.test.ts b/frontend/src/apollo/client.test.ts
index 07633b3..4008deb 100644
--- a/frontend/src/apollo/client.test.ts
+++ b/frontend/src/apollo/client.test.ts
@@ -116,6 +116,19 @@ describe('API-status fra error-linken', () => {
expect(apiUnavailable()).toBe(false);
});
+ it('settes når backenden melder SERVICE_UNAVAILABLE (database nede)', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ Response.json({
+ errors: [{ message: 'Nede', extensions: { code: 'SERVICE_UNAVAILABLE' } }],
+ }),
+ ),
+ );
+ await expect(createClient().query(noCache)).rejects.toThrow();
+ expect(apiUnavailable()).toBe(true);
+ });
+
it('nullstilles når et kall lykkes', async () => {
const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch'));
fetchMock.mockImplementation(async () => ok());
diff --git a/frontend/src/components/ApiUnavailableBanner.test.tsx b/frontend/src/components/ApiUnavailableBanner.test.tsx
index 8602c80..d049eea 100644
--- a/frontend/src/components/ApiUnavailableBanner.test.tsx
+++ b/frontend/src/components/ApiUnavailableBanner.test.tsx
@@ -1,6 +1,6 @@
import { ApolloProvider } from '@apollo/client/react';
import type { ApolloClient } from '@apollo/client';
-import { act, render, screen } from '@testing-library/react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { apiUnavailable } from '../apollo/apiStatus';
@@ -11,6 +11,7 @@ function setup(refetchQueries: () => Promise) {
render(
+
,
);
return client;
@@ -53,4 +54,26 @@ describe('ApiUnavailableBanner', () => {
await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' }));
expect(await screen.findByRole('button', { name: 'Prøv igjen' })).toBeEnabled();
});
+
+ it('gir tilbakemelding i en status-region (ikke alerten) når forsøket mislykkes', async () => {
+ apiUnavailable(true);
+ setup(async () => []);
+ const status = screen.getByRole('status');
+ expect(status).toBeEmptyDOMElement();
+ await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' }));
+ expect(await screen.findByText('Fortsatt ingen kontakt med serveren.')).toBe(status);
+ expect(screen.getByRole('alert')).not.toContainElement(status);
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
+ });
+
+ it('flytter fokus til main når forsøket lykkes og knappen forsvinner', async () => {
+ apiUnavailable(true);
+ setup(async () => {
+ apiUnavailable(false);
+ return [];
+ });
+ await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' }));
+ await waitFor(() => expect(screen.getByRole('main')).toHaveFocus());
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/ApiUnavailableBanner.tsx b/frontend/src/components/ApiUnavailableBanner.tsx
index 5362431..737360a 100644
--- a/frontend/src/components/ApiUnavailableBanner.tsx
+++ b/frontend/src/components/ApiUnavailableBanner.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useApolloClient } from '@apollo/client/react';
-import { useApiUnavailable } from '../apollo/apiStatus';
+import { apiUnavailable, useApiUnavailable } from '../apollo/apiStatus';
/**
* Ett felles banner i stedet for én feil per rad. Selve meldingen ligger i role="alert" og
@@ -10,17 +10,24 @@ export function ApiUnavailableBanner() {
const unavailable = useApiUnavailable();
const client = useApolloClient();
const [retrying, setRetrying] = useState(false);
+ const [failed, setFailed] = useState(false);
if (!unavailable) return null;
const retry = async () => {
setRetrying(true);
+ setFailed(false);
try {
await client.refetchQueries({ include: 'active' });
} catch {
// Banneret blir stående hvis det fortsatt feiler; ingenting mer å gjøre her.
- } finally {
- setRetrying(false);
+ }
+ setRetrying(false);
+ if (apiUnavailable()) {
+ setFailed(true);
+ } else {
+ // Knappen forsvant mens den hadde fokus; uten dette havner fokus på body og tastaturbrukeren mister plassen.
+ document.getElementById('innhold')?.focus({ preventScroll: true });
}
};
@@ -39,6 +46,10 @@ export function ApiUnavailableBanner() {
+ {/* Egen status-region utenfor alerten: tilbakemelding på mislykket forsøk uten å gjenta hele varselet. */}
+
+ {failed ? 'Fortsatt ingen kontakt med serveren.' : ''}
+
);
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 5d6f417..e478e26 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1430,3 +1430,8 @@ fieldset {
margin-top: 0.25rem;
color: var(--muted);
}
+.api-banner__status {
+ flex-basis: 100%;
+ margin: 0;
+ color: var(--muted);
+}
From 6f28dead53a0c1a70a531c9d149efb9f042130d8 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:20:45 +0000
Subject: [PATCH 012/132] fix(frontend): keep focus on category rows and harden
nav behaviour
---
e2e/category-nav.spec.ts | 34 ++++++++++++++++--
frontend/src/components/CategoryNav.tsx | 31 ++++++++++-------
frontend/src/pages/HomePage.test.tsx | 46 +++++++++++++++++++++++--
frontend/src/styles/global.css | 11 +++---
frontend/src/test/utils.tsx | 24 ++++++++++---
5 files changed, 118 insertions(+), 28 deletions(-)
diff --git a/e2e/category-nav.spec.ts b/e2e/category-nav.spec.ts
index f5d487d..a39a906 100644
--- a/e2e/category-nav.spec.ts
+++ b/e2e/category-nav.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-test('hopp til en rad langt nede med tastatur: overskriften får fokus og raden laster titler', async ({
+test('hopp til en rad langt nede med tastatur: raden får fokus og laster titler', async ({
page,
}) => {
await page.goto('./');
@@ -8,12 +8,12 @@ test('hopp til en rad langt nede med tastatur: overskriften får fokus og raden
const nav = page.getByRole('navigation', { name: 'Kategorier' });
const link = nav.getByRole('link', { name: 'Science fiction' });
- // Fokus og Enter, slik et tastatur- eller skjermleserbrukere gjør det.
+ // Fokus og Enter, slik en tastatur- eller skjermleserbruker gjør det.
await link.focus();
await page.keyboard.press('Enter');
const heading = page.getByRole('heading', { level: 2, name: 'Science fiction' });
- await expect(heading).toBeFocused();
+ await expect(page.getByRole('group', { name: 'Science fiction' })).toBeFocused();
// Raden var en tom plassholder før hoppet; scrollingen må ha utløst lastingen.
await expect(
page.getByRole('region', { name: 'Science fiction' }).getByRole('article').first(),
@@ -32,3 +32,31 @@ test('hopp til en rad langt nede med tastatur: overskriften får fokus og raden
),
).toBe(true);
});
+
+test('320 px: ingen sidescroll, og siste kategori kan nås med Tab og er synlig', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 320, height: 640 });
+ await page.goto('./');
+ const nav = page.getByRole('navigation', { name: 'Kategorier' });
+ const links = nav.getByRole('link');
+ const last = links.last();
+ await expect(last).toHaveText('Science fiction');
+
+ // Tab fra første kategori og helt til siste.
+ await links.first().focus();
+ const n = await links.count();
+ for (let i = 1; i < n; i++) await page.keyboard.press('Tab');
+ await expect(last).toBeFocused();
+
+ // Fokus skal ha scrollet chipsen inn i nav-en, ikke ut av skjermen.
+ // Nettleseren scroller chipsen inn i nav-en (kan ta et øyeblikk), så vi poller.
+ const right = async () => (await last.boundingBox())!.x + (await last.boundingBox())!.width;
+ await expect.poll(right).toBeLessThanOrEqual(320);
+ await expect.poll(async () => (await last.boundingBox())!.x).toBeGreaterThanOrEqual(0);
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+});
diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx
index 40982f8..8bdddcd 100644
--- a/frontend/src/components/CategoryNav.tsx
+++ b/frontend/src/components/CategoryNav.tsx
@@ -1,21 +1,20 @@
import type { MouseEvent } from 'react';
import { rowAnchorId, type BrowseRow } from '../lib/browseRows';
-const prefersReducedMotion = () =>
- typeof window.matchMedia === 'function' &&
- window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-
-function jumpTo(id: string) {
+function jumpTo(id: string, label: string) {
const target = document.getElementById(id);
if (!target) return;
- // Scroll først: da nærmer raden seg viewport og LazyRow starter hentingen av seg selv.
+ // Umiddelbart hopp (ikke myk scroll): en myk scroll over mange rader ville utløst lasting av alle
+ // radene på veien og landet unøyaktig mens høyden endres. Det respekterer også prefers-reduced-motion.
// `scrollIntoView` kan mangle i testmiljø; hoppet er en forbedring, fokus er det viktige.
- target.scrollIntoView?.({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' });
- // Overskriften er ikke fokuserbar fra før. tabIndex=-1 lar oss flytte fokus dit uten å legge
- // den inn i tab-rekkefølgen. `preventScroll` hindrer at nettleseren avbryter den myke scrollingen.
- const heading = target.querySelector('h2') ?? target;
- heading.tabIndex = -1;
- heading.focus({ preventScroll: true });
+ target.scrollIntoView?.({ behavior: 'auto', block: 'start' });
+ // Fokus går til radens ytre element, ikke til overskriften: raden kan senere bli tom og fjerne
+ // overskriften fra DOM-en, og da ville fokus falt til body. Elementet får navn og rolle her fordi
+ // en navnløs div ikke leses opp. `preventScroll` hindrer at nettleseren flytter seg på nytt.
+ target.tabIndex = -1;
+ target.setAttribute('role', 'group');
+ target.setAttribute('aria-label', label);
+ target.focus({ preventScroll: true });
}
/**
@@ -24,8 +23,10 @@ function jumpTo(id: string) {
*/
export function CategoryNav({ rows }: { rows: BrowseRow[] }) {
const onClick = (e: MouseEvent, row: BrowseRow) => {
+ // Ctrl/Cmd/Shift/Alt og midtklikk skal beholde nettleserens vanlige lenkeoppførsel.
+ if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
- jumpTo(rowAnchorId(row));
+ jumpTo(rowAnchorId(row), row.heading);
};
return (
@@ -37,6 +38,10 @@ export function CategoryNav({ rows }: { rows: BrowseRow[] }) {
href={`#${rowAnchorId(row)}`}
className="catnav__link"
onClick={(e) => onClick(e, row)}
+ // Chrome lar en delvis synlig chip stå halvt klippet når man tabber til den.
+ onFocus={(e) =>
+ e.currentTarget.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
+ }
>
{row.heading}
diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx
index dc27f1b..b73ae7d 100644
--- a/frontend/src/pages/HomePage.test.tsx
+++ b/frontend/src/pages/HomePage.test.tsx
@@ -221,6 +221,24 @@ describe('HomePage: filtre', () => {
).not.toBeChecked();
});
+ it('avkrysningsboksen er avkrysset umiddelbart etter klikk, uten å vente på routeren', async () => {
+ // Regresjon: uten flushSync tilbakestilte React den kontrollerte boksen til forrige verdi til
+ // router-transitionen var ferdig, og den hoppet synlig tilbake.
+ setup();
+ const box = await screen.findByRole('checkbox', {
+ name: /^Kun filmer som kan strømmes gratis/,
+ });
+ // Rå DOM-klikk utenfor act(): Testing Librarys hjelpere ville ellers flushet transitionen og
+ // skjult feilen.
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
+ try {
+ box.click();
+ expect(box).toBeChecked();
+ } finally {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ }
+ });
+
it('leser available=1 fra URL, og «Nullstill alle» fjerner den', async () => {
const { user, log } = setup('/?q=alien&available=1');
expect(
@@ -386,6 +404,28 @@ describe('HomePage: bla-modus', () => {
);
});
+ it('hopp til en tom rad gir fokus på raden, ikke på body', async () => {
+ const io = stubIntersectionObserver();
+ const { user } = setup('/', (vars) => ({
+ search: makeConnection(
+ (vars.filters as { availableOnly?: boolean })?.availableOnly ? [] : titles,
+ ),
+ }));
+ await screen.findByRole('region', { name: 'Med bilde' });
+ const nav = screen.getByRole('navigation', { name: 'Kategorier' });
+ await user.click(within(nav).getByRole('link', { name: 'Se gratis nå' }));
+ const row = screen.getByRole('group', { name: 'Se gratis nå' });
+ expect(row).toHaveFocus();
+
+ // Raden laster og viser seg tom; overskriften forsvinner, men fokus blir på raden.
+ io.reveal(document.getElementById('rad-free')!);
+ await waitFor(() =>
+ expect(screen.queryByRole('heading', { name: 'Se gratis nå' })).not.toBeInTheDocument(),
+ );
+ expect(row).toHaveFocus();
+ expect(document.body).not.toHaveFocus();
+ });
+
it('viser «Se filmen» i hero når hero-tittelen har stream', async () => {
stubIntersectionObserver();
const log = emptyLog();
@@ -456,7 +496,7 @@ describe('HomePage: bla-modus', () => {
expect(nav.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
- it('flytter fokus til radens overskrift ved klikk og Enter, uten å endre URL', async () => {
+ it('flytter fokus til raden ved klikk og Enter, uten å endre URL', async () => {
stubIntersectionObserver();
const { user } = setup('/');
await screen.findByRole('region', { name: 'Med bilde' });
@@ -464,13 +504,13 @@ describe('HomePage: bla-modus', () => {
const before = currentUrl().href;
await user.click(within(nav).getByRole('link', { name: 'Drama' }));
- const drama = screen.getByRole('heading', { name: 'Drama' });
+ const drama = screen.getByRole('group', { name: 'Drama' });
expect(drama).toHaveFocus();
expect(drama).toHaveAttribute('tabindex', '-1');
within(nav).getByRole('link', { name: 'Komedie' }).focus();
await user.keyboard('{Enter}');
- expect(screen.getByRole('heading', { name: 'Komedie' })).toHaveFocus();
+ expect(screen.getByRole('group', { name: 'Komedie' })).toHaveFocus();
expect(currentUrl().href).toBe(before);
});
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 96e105c..3093209 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1406,7 +1406,7 @@ fieldset {
border-radius: 0;
}
-/* === Kategorinavigasjon på forsiden (#14) – samlet her for enkel merge === */
+/* === Kategorinavigasjon på forsiden (#14) === */
.catnav {
padding-top: 0.75rem;
}
@@ -1415,10 +1415,13 @@ fieldset {
gap: 0.5rem;
list-style: none;
margin: 0;
- padding: 0.25rem 0.25rem 0.5rem;
+ /* Rom nok til fokusringen (3px + 2px offset), ellers klippes den av overflow. */
+ padding: 0.375rem;
/* Smal skjerm: én rad som scroller sidelengs inni navigasjonen, så siden selv aldri får sidescroll. */
overflow-x: auto;
overscroll-behavior-x: contain;
+ /* Samme rom som paddingen, slik at en fokusert chip ikke ender helt i kanten med klippet ring. */
+ scroll-padding-inline: 0.375rem;
}
.catnav__list li {
flex: none;
@@ -1448,7 +1451,7 @@ fieldset {
.row-anchor {
scroll-margin-top: calc(var(--header-h) + 0.5rem);
}
-.row-anchor h2:focus-visible {
- outline-offset: 4px;
+.row-anchor:focus-visible {
+ outline-offset: -3px;
}
/* === Slutt: kategorinavigasjon === */
diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx
index 07e9960..3e479b2 100644
--- a/frontend/src/test/utils.tsx
+++ b/frontend/src/test/utils.tsx
@@ -3,7 +3,10 @@ import type { ReactElement } from 'react';
import { MockedProvider } from '@apollo/client/testing/react';
import type { MockLink } from '@apollo/client/testing';
import { render, screen } from '@testing-library/react';
-import { MemoryRouter, useLocation } from 'react-router';
+import { createMemoryRouter, useLocation } from 'react-router';
+// /dom-varianten kobler på ReactDOM.flushSync, slik som i main.tsx; uten den ville testene ikke
+// fange at URL-endringer må committes synkront.
+import { RouterProvider } from 'react-router/dom';
import { createCache } from '../apollo/cache';
import {
ADD_REVIEW_MUTATION,
@@ -91,12 +94,23 @@ export function renderApp(
ui: ReactElement,
{ mocks = [], route = '/' }: { mocks?: MockLink.MockedResponse[]; route?: string } = {},
) {
+ const router = createMemoryRouter(
+ [
+ {
+ path: '*',
+ element: (
+ <>
+ {ui}
+
+ >
+ ),
+ },
+ ],
+ { initialEntries: [route] },
+ );
return render(
-
- {ui}
-
-
+ ,
);
}
From 96d67ae027a5137cb46c3258c57821403fc3d888 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:27:53 +0000
Subject: [PATCH 013/132] fix(backend): normalize before escaping LIKE patterns
and use word-prefix search for short queries (#15)
Fullwidth characters are mapped to % and _ by unaccent, so escaping must happen after normalization. Queries of 1-2 characters now use a generated tsvector with GIN instead of whole-title prefix matching.
---
backend/migrations/004_unaccent_search.sql | 26 +++++---
backend/src/search.ts | 38 ++++++++----
backend/test/search-index.test.ts | 6 +-
backend/test/search.test.ts | 71 +++++++++++++++++-----
4 files changed, 105 insertions(+), 36 deletions(-)
diff --git a/backend/migrations/004_unaccent_search.sql b/backend/migrations/004_unaccent_search.sql
index d8efe8c..6e32b8d 100644
--- a/backend/migrations/004_unaccent_search.sql
+++ b/backend/migrations/004_unaccent_search.sql
@@ -1,12 +1,13 @@
--- Aksentuavhengig søk («amelie» finner «Amélie») og prefiksindekser for korte søk (1–2 tegn).
+-- Aksentuavhengig søk («amelie» finner «Amélie») og ordprefiks for korte søk (1–2 tegn).
CREATE EXTENSION IF NOT EXISTS unaccent;
-- unaccent() er bare STABLE (ordlisten kan byttes), og Postgres krever IMMUTABLE i indeksuttrykk og
-- genererte kolonner. Standardgrepet er en IMMUTABLE innpakning som peker på ordlisten med fullt
-- kvalifisert navn, slik at den ikke avhenger av search_path. Ordlisten endres i praksis aldri;
--- skulle den gjøre det, må kolonnene og indeksene bygges på nytt. STRICT lar planleggeren folde kall
--- med konstant argument (søketeksten), slik at indeksene kan brukes.
+-- skulle den gjøre det, må kolonnene og indeksene bygges på nytt. Det er IMMUTABLE som lar
+-- planleggeren folde kall med konstant argument (søketeksten) til en konstant, slik at indeksene kan
+-- brukes; STRICT og PARALLEL SAFE er bare ekstra optimalisering.
CREATE FUNCTION f_unaccent(text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
AS $$ SELECT public.unaccent('public.unaccent', $1) $$;
@@ -15,9 +16,17 @@ CREATE FUNCTION f_unaccent(text) RETURNS text
-- indeksuttrykk `lower(f_unaccent(primary_title))` ville også virket, men da regnes unaccent ut
-- på nytt for hver rad som leses i recheck, filter og relevansberegning (målt: ca. 2x tregere på
-- brede søk som «the»; se docs/ytelse.md). Prisen er ca. 2 x tittellengde ekstra lagring per rad.
+--
+-- Alle tre kolonnene legges til i ÉN ALTER TABLE, fordi hver slik kolonne skriver om hele tabellen
+-- mens den holder ACCESS EXCLUSIVE-lås (ingen lesing eller skriving på titles under migreringen).
+-- ca. 4 s lokalt på 120 000 titler (regn med 10–20 s på VM); stopp gjerne backend under migreringen (se docs/deploy.md).
+-- title_words er en tsvector av begge titlene til ordprefikssøk (se lenger ned).
ALTER TABLE titles
ADD COLUMN primary_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(primary_title))) STORED,
- ADD COLUMN original_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(original_title))) STORED;
+ ADD COLUMN original_title_norm text GENERATED ALWAYS AS (lower(f_unaccent(original_title))) STORED,
+ ADD COLUMN title_words tsvector GENERATED ALWAYS AS (
+ to_tsvector('simple', lower(f_unaccent(primary_title)) || ' ' || lower(f_unaccent(original_title)))
+ ) STORED;
-- Trigramindeksene byttes til de normaliserte kolonnene (se src/search.ts).
DROP INDEX titles_primary_title_trgm_idx;
@@ -27,7 +36,8 @@ CREATE INDEX titles_primary_title_trgm_idx
CREATE INDEX titles_original_title_trgm_idx
ON titles USING gin (original_title_norm gin_trgm_ops);
--- Søk på 1–2 tegn ble tidligere delstrengsøk uten bruk av indeks. De gjøres nå om til prefiksmatch
--- (LIKE 'a%'), som en btree med text_pattern_ops kan svare på uavhengig av collation.
-CREATE INDEX titles_primary_title_prefix_idx ON titles (primary_title_norm text_pattern_ops);
-CREATE INDEX titles_original_title_prefix_idx ON titles (original_title_norm text_pattern_ops);
+-- Søk på 1–2 tegn har ingen trigrammer. De gjøres om til ORDprefiks («ma» finner «The Matrix»)
+-- mot en lagret tsvector av begge titlene, med GIN. 'simple' (ingen stemming eller stoppord) fordi
+-- titler ikke er prosa og «the» skal kunne søkes på. Dette erstatter prefiksindekser på hele
+-- tittelen, som bare fant titler som BEGYNTE med søketeksten.
+CREATE INDEX titles_title_words_idx ON titles USING gin (title_words);
diff --git a/backend/src/search.ts b/backend/src/search.ts
index 641c008..ce468e9 100644
--- a/backend/src/search.ts
+++ b/backend/src/search.ts
@@ -44,11 +44,6 @@ class Params {
}
}
-/** Gjør om brukerens tekst til et LIKE-mønster der %, _ og \ betyr seg selv. */
-export function escapeLike(text: string): string {
- return text.replace(/[\\%_]/g, (c) => `\\${c}`);
-}
-
/**
* Normalisert tittel (små bokstaver, uten aksenter) ligger som genererte kolonner, med indekser på
* dem (004_unaccent_search.sql). Søketeksten må normaliseres med nøyaktig samme uttrykk, og det
@@ -60,11 +55,31 @@ const norm = (expr: string) => `lower(f_unaccent(${expr}))`;
const TITLE_NORM = 't.primary_title_norm';
const ORIGINAL_NORM = 't.original_title_norm';
+/**
+ * LIKE-mønster for delstrengsøk der %, _ og \ i søketeksten betyr seg selv. Rekkefølgen er viktig:
+ * normaliser FØRST (unaccent), escape ETTERPÅ. unaccent.rules mapper bl.a. fullbreddetegnene
+ * `%`, `_` og `\` til `%`, `_` og `\`; escapet vi før normaliseringen, ble de jokertegn og
+ * traff hele tabellen. Begge trinn gjøres i Postgres. Regex-strengene er vanlige SQL-strenger
+ * (standard_conforming_strings = on): `[\\%_]` er en tegnklasse med `\`, `%` og `_`, og `\\\1` i
+ * erstatningen er en backslash etterfulgt av treffet.
+ */
+const likePattern = (param: string) =>
+ String.raw`'%' || regexp_replace(${norm(`${param}::text`)}, '([\\%_])', '\\\1', 'g') || '%'`;
+
/**
* Søk på 1–2 tegn har ingen trigrammer, så GIN-indeksene kan ikke brukes og delstrengsøk ville
* lest hele tabellen og regnet likhet på nesten alle rader (ca. 0,5 s på 120 000 titler). Slike
- * søk er derfor prefiksmatch med btree-indeks og rangeres etter popularitet (se docs/ytelse.md).
+ * søk er derfor ORDprefiks («ma» finner «The Matrix») mot en generert tsvector med GIN-indeks, og
+ * rangeres etter popularitet (se docs/ytelse.md).
+ *
+ * Søketeksten strippes for tegnsetting, mellomrom og kontrolltegn FØR den settes inn i
+ * tsquery-syntaksen, fordi `& | ! ( ) : * < > ' \` ellers er operatorer eller gir syntaksfeil. Det
+ * som er igjen er bokstaver og sifre, så uttrykket kan ikke kaste feil. Blir ingenting igjen
+ * (f.eks. «%» eller «'») gir NULLIF NULL, og dermed ingen treff.
*/
+const wordPrefixQuery = (param: string) =>
+ `to_tsquery('simple', NULLIF(regexp_replace(${norm(`${param}::text`)}, '[[:punct:][:space:][:cntrl:]]', '', 'g'), '') || ':*')`;
+
export const SHORT_QUERY_MAX = 2;
export const isShortQuery = (query: string): boolean => [...query].length <= SHORT_QUERY_MAX;
@@ -85,11 +100,12 @@ function filterConditions(
): string[] {
const conds: string[] = [];
if (query) {
- // Normaliseringen gjøres i Postgres på begge sider, så samme uttrykk brukes som i indeksen.
- // Prefiks for korte søk (btree), delstreng ellers (trigram-GIN).
- const pattern = isShortQuery(query) ? `${escapeLike(query)}%` : `%${escapeLike(query)}%`;
- const like = norm(p.add(pattern));
- conds.push(`(${TITLE_NORM} LIKE ${like} OR ${ORIGINAL_NORM} LIKE ${like})`);
+ if (isShortQuery(query)) {
+ conds.push(`t.title_words @@ ${wordPrefixQuery(p.add(query))}`);
+ } else {
+ const like = likePattern(p.add(query));
+ conds.push(`(${TITLE_NORM} LIKE ${like} OR ${ORIGINAL_NORM} LIKE ${like})`);
+ }
}
if (skip !== 'genres' && filters.genres.length > 0) {
// @> = «inneholder alle»; bruker GIN-indeksen på titles.genres.
diff --git a/backend/test/search-index.test.ts b/backend/test/search-index.test.ts
index c1c4448..2c8155f 100644
--- a/backend/test/search-index.test.ts
+++ b/backend/test/search-index.test.ts
@@ -77,10 +77,10 @@ describe('indeksbruk', () => {
},
);
- it.each(['a', 'da', 'ça'])('kort søk «%s» bruker prefiksindeksene, ikke trigram', async (q) => {
+ it.each(['a', 'da', 'ça'])('kort søk «%s» bruker ordprefiksindeksen, ikke trigram', async (q) => {
const plan = await planFor(q);
- expect(plan).toMatch(/titles_primary_title_prefix_idx/);
- expect(plan).toMatch(/titles_original_title_prefix_idx/);
+ expect(plan).toMatch(/titles_title_words_idx/);
+ expect(plan).not.toMatch(/trgm_idx/);
});
it('søket regner ikke likhet for korte søk', async () => {
diff --git a/backend/test/search.test.ts b/backend/test/search.test.ts
index 9d20426..b51ab99 100644
--- a/backend/test/search.test.ts
+++ b/backend/test/search.test.ts
@@ -146,6 +146,21 @@ describe('search: aksentuavhengig', () => {
expect((await search({ query: 'Back\\Slash' })).titles).toEqual(['Back\\Slash']);
});
+ // unaccent.rules mapper fullbreddetegn til ASCII. Hvis escaping skjedde FØR normaliseringen,
+ // ble de jokertegn og traff hele tabellen.
+ it.each(['caf% soc', 'caf_ soc', '%%%', '___', 'ba\sla%', 'bac\sl', '\\\'])(
+ 'fullbreddetegn i %j blir bokstavelige og gir ikke jokertegn',
+ async (q) => {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ expect(r.total).toBe(0);
+ },
+ );
+
+ it('fullbredde-backslash normaliseres til en vanlig backslash', async () => {
+ expect((await search({ query: 'back\sl' })).titles).toEqual(['Back\\Slash']);
+ });
+
it('fasettene og totalCount bruker samme normalisering', async () => {
const res = await env.gql(
'query ($q: String) { facets(query: $q) { available types { value count } } }',
@@ -157,14 +172,18 @@ describe('search: aksentuavhengig', () => {
});
describe('search: korte søk (1–2 tegn)', () => {
- // Prefiksmatch på normalisert primær- eller originaltittel, rangert etter stemmer og id.
+ // ORDprefiks på normalisert primær- eller originaltittel, rangert etter stemmer og id.
+ const words = (t: (typeof TITLES)[number]) =>
+ titleHaystack(t)
+ .flatMap((h) => h.split(/[^\p{L}\p{N}]+/u))
+ .filter(Boolean);
const expectedPrefix = (q: string) =>
- TITLES.filter((t) => titleHaystack(t).some((h) => h.startsWith(fold(q))))
+ TITLES.filter((t) => words(t).some((w) => w.startsWith(fold(q))))
.sort((a, b) => b.votes - a.votes || (a.id < b.id ? 1 : -1))
.map((t) => t.id);
- it.each(['u', 'U', 'f', 'un', 'UN', 'ba', 'br', 'ca', 'ÇA', 'am', 'it', 'e'])(
- 'prefiksmatch for %j, sortert etter popularitet',
+ it.each(['u', 'U', 'f', 'un', 'UN', 'ba', 'br', 'ca', 'ÇA', 'am', 'it', 'e', 'ci', 'ni'])(
+ 'ordprefiks for %j, sortert etter popularitet',
async (q) => {
const r = await search({ query: q });
expect(r.res.errors).toBeUndefined();
@@ -174,27 +193,51 @@ describe('search: korte søk (1–2 tegn)', () => {
},
);
- it('finner ikke midt i tittelen (til forskjell fra søk på 3+ tegn)', async () => {
+ it('finner ord midt i tittelen, men ikke midt i et ord', async () => {
+ // «ba» finner «Breaking Bad» (andre ord), «ci» finner «Dark City».
+ expect((await search({ query: 'ba' })).titles).toContain('Breaking Bad');
+ expect((await search({ query: 'ci' })).titles).toContain('Dark City');
expect((await search({ query: 'ar' })).titles).not.toContain('Dark');
expect((await search({ query: 'ark' })).titles).toContain('Dark');
});
- it.each(['%', '_', '\\', 'u_', '1%', '%%', '\\%', '"', "'", '🎬', '🎬🎬'])(
- 'behandler %j som vanlig tegn og ikke jokertegn',
+ it('finner ord i originaltittelen, også uten aksent', async () => {
+ expect((await search({ query: 'fa' })).titles).toContain('Amélie');
+ expect((await search({ query: 'ça' })).titles).toContain('It');
+ });
+
+ it.each(['%', '_', '\\', '%%', '"', "'", '🎬', '🎬🎬', '%', '_', '\', '%%', '__'])(
+ 'behandler %j (jokertegn, fullbredde, emoji) uten treff og uten feil',
async (q) => {
const r = await search({ query: q });
expect(r.res.errors).toBeUndefined();
- expect(r.ids).toEqual(expectedPrefix(q));
- expect(r.total).toBe(expectedPrefix(q).length);
- // Ingen titler starter med disse tegnene, så et jokertegn ville gitt mange treff.
expect(r.total).toBe(0);
},
);
- it('teller emoji som ett tegn', async () => {
- // To emoji = 2 tegn = fortsatt prefiksmatch (4 UTF-16-enheter ville ellers gitt delstrengsøk).
- expect((await search({ query: 'E🎬' })).total).toBe(0);
- expect((await search({ query: 'Em' })).titles).toEqual(['Emoji 🎬 Night']);
+ it.each([
+ ':',
+ '&',
+ '|',
+ '!',
+ '(',
+ ')',
+ '<',
+ '>',
+ '*',
+ ':*',
+ 'a:',
+ '!a',
+ 'a&',
+ '(a',
+ "a'",
+ 'a\\',
+ '-a',
+ ])('tsquery-tegn i %j gir ikke feil og ignoreres', async (q) => {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ const letters = q.replace(/[^a-z]/g, '');
+ expect(r.ids).toEqual(letters ? expectedPrefix(letters) : []);
});
it('pagineres uten hull eller duplikater, og totalCount stemmer', async () => {
From 2f10cb2770200269ceb38bfbd99969fdb90a24b0 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com>
Date: Tue, 6 Oct 2026 18:27:56 +0000
Subject: [PATCH 014/132] fix(backend): memoize fragment traversal, weigh list
fields by first and trust X-Forwarded-For only from loopback (#15)
Bind to 127.0.0.1 in production so port 3001 cannot be reached around Apache.
---
backend/src/app.ts | 31 ++++-
backend/src/complexityLimit.test.ts | 69 +++++++++++-
backend/src/complexityLimit.ts | 168 ++++++++++++++++++----------
backend/src/config.ts | 4 +
backend/src/context.ts | 3 +-
backend/src/depthLimit.test.ts | 13 +++
backend/src/depthLimit.ts | 25 +++--
backend/src/index.ts | 4 +-
backend/src/rateLimit.ts | 22 +++-
backend/src/typeDefs.ts | 2 +-
backend/test/rate-limit.test.ts | 63 ++++++++++-
11 files changed, 312 insertions(+), 92 deletions(-)
diff --git a/backend/src/app.ts b/backend/src/app.ts
index c1fe184..a1708e0 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -10,11 +10,20 @@ import { depthLimit } from './depthLimit.js';
import { DEFAULT_RATE_LIMITS, MutationLimiter, type RateLimitOptions } from './rateLimit.js';
import { schema } from './schema.js';
+/** Serverkonteksten Yoga får fra node:http; fravær av `req` betyr kall via yoga.fetch (tester). */
+interface NodeServerContext {
+ req?: { socket?: { remoteAddress?: string } };
+}
+
export const MAX_QUERY_DEPTH = 6;
// Frontendens største spørring (Title-detaljene) har ca. 45 felt og ett rotfelt. Grensene gir rundt
// tre ganger slingringsmonn, men stopper hundrevis av aliasede søk eller TMDB-oppslag i ett dokument.
export const MAX_ROOT_FIELDS = 8;
export const MAX_FIELDS = 150;
+// Vektet kostnad (felt under en liste teller `first` ganger, variabel `first` = 50). Frontendens
+// største spørring (forsiden/Featured) ligger på ca. 1 100; 2 500 slipper den gjennom to ganger,
+// men stopper f.eks. 8 x search(first: 50) med nøstede anmeldelser.
+export const MAX_COST = 2500;
/**
* Egen regel i stedet for graphqls NoSchemaIntrospectionCustomRule: den bruker instanceof-sjekker
@@ -90,7 +99,13 @@ export function createApp({
const limits: Plugin = {
onValidate({ addValidationRule }) {
addValidationRule(depthLimit(MAX_QUERY_DEPTH));
- addValidationRule(complexityLimit({ maxRootFields: MAX_ROOT_FIELDS, maxFields: MAX_FIELDS }));
+ addValidationRule(
+ complexityLimit({
+ maxRootFields: MAX_ROOT_FIELDS,
+ maxFields: MAX_FIELDS,
+ maxCost: MAX_COST,
+ }),
+ );
if (isProd) addValidationRule(noIntrospection);
},
};
@@ -110,13 +125,21 @@ export function createApp({
},
};
- return createYoga