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({ schema, graphqlEndpoint: '/graphql', - plugins: [limits], + healthCheckEndpoint: '/__yoga-health', + plugins: [health, limits], context: ({ request }) => createContext(pool, request, artworkService, archiveUrl ?? config.archiveUrl), logging: process.env.NODE_ENV !== 'test', diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts index 45aa49e..4cc5f0b 100644 --- a/backend/test/server.test.ts +++ b/backend/test/server.test.ts @@ -24,6 +24,26 @@ afterAll(async () => { await pool.end(); }); +describe('GET /health', () => { + it('svarer 200 {status:"ok"} når databasen svarer', async () => { + const res = await createApp({ pool }).fetch('http://localhost/health'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); + + it('svarer 503 {status:"db-unavailable"} når databasen ikke svarer', async () => { + // Port 1 avviser tilkoblinger umiddelbart, som en nedlagt Postgres. + const dead = createPool('postgres://postgres:x@127.0.0.1:1/none'); + try { + const res = await createApp({ pool: dead }).fetch('http://localhost/health'); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ status: 'db-unavailable' }); + } 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/apache-project2.conf b/deploy/apache-project2.conf index 5fdcc44..f80460f 100644 --- a/deploy/apache-project2.conf +++ b/deploy/apache-project2.conf @@ -7,6 +7,9 @@ ProxyPreserveHost On ProxyPass /project2/graphql http://127.0.0.1:3001/graphql ProxyPassReverse /project2/graphql http://127.0.0.1:3001/graphql +# Helsesjekk (SELECT 1 mot databasen): lar sjekk.sh og overvåking skille «API nede» fra «database nede». +ProxyPass /project2/health http://127.0.0.1:3001/health +ProxyPassReverse /project2/health http://127.0.0.1:3001/health Alias /project2 /var/www/html/project2 @@ -50,3 +53,6 @@ Alias /project2 /var/www/html/project2 Header set Cache-Control "no-store" + + Header set Cache-Control "no-store" + diff --git a/deploy/project2-backend.service b/deploy/project2-backend.service index 2fad3b6..4270903 100644 --- a/deploy/project2-backend.service +++ b/deploy/project2-backend.service @@ -5,7 +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 -Wants=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 [Service] Type=simple @@ -16,7 +18,8 @@ WorkingDirectory=/opt/project2/backend EnvironmentFile=/opt/project2/.env Environment=NODE_ENV=production ExecStart=/usr/bin/node dist/src/index.js -Restart=on-failure +# always: også etter rene avslutninger (exit 0), slik at en uventet stopp aldri blir stående som nedetid. +Restart=always RestartSec=5 # Herding: prosessen trenger bare å lese koden og snakke med Postgres over nettverket. diff --git a/deploy/sjekk.sh b/deploy/sjekk.sh index 130a6af..e7604d5 100755 --- a/deploy/sjekk.sh +++ b/deploy/sjekk.sh @@ -16,6 +16,10 @@ curl -sS -m 10 -X POST http://127.0.0.1:3001/graphql -H 'content-type: applicati line "2. Via Apache (/project2/graphql)" curl -sS -m 10 -i -X POST http://localhost/project2/graphql -H 'content-type: application/json' -d "$Q" | head -n 20 +line "2b. Helsesjekk (backend direkte og via Apache)" +curl -sS -m 5 -w ' (HTTP %{http_code})\n' http://127.0.0.1:3001/health +curl -sS -m 5 -w ' (HTTP %{http_code})\n' http://localhost/project2/health + line "7. Samme spørringer som forsiden sender (via Apache)" UID_HDR='x-user-id: 00000000-0000-4000-8000-000000000000' curl -sS -m 20 -w '\n(HTTP %{http_code}, %{time_total}s)\n' -X POST http://localhost/project2/graphql \ diff --git a/docs/beslutninger.md b/docs/beslutninger.md index 5c7973d..160f79f 100644 --- a/docs/beslutninger.md +++ b/docs/beslutninger.md @@ -169,3 +169,10 @@ 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. + +## Feiltilstand når API-et ikke nås (#13) + +- **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`. diff --git a/docs/deploy.md b/docs/deploy.md index befbdfb..354e4bb 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -49,6 +49,10 @@ curl -s -X POST localhost:3001/graphql -H 'content-type: application/json' \ -d '{"query":"{ search(first: 1) { totalCount } }"}' ``` +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. + ## 4. Apache ```bash diff --git a/docs/ki-logg.md b/docs/ki-logg.md index 9fab6f7..1459843 100644 --- a/docs/ki-logg.md +++ b/docs/ki-logg.md @@ -15,3 +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. diff --git a/e2e/api-unavailable.spec.ts b/e2e/api-unavailable.spec.ts new file mode 100644 index 0000000..b8a0abe --- /dev/null +++ b/e2e/api-unavailable.spec.ts @@ -0,0 +1,53 @@ +import { AxeBuilder } from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +// Simulerer en student utenfor NTNU-nettet: fetch mot API-et feiler helt (som uten VPN). +const GRAPHQL = '**/project2/graphql'; + +test('nedetid gir ett banner, og «Prøv igjen» henter data når API-et er tilbake', async ({ + page, +}) => { + await page.route(GRAPHQL, (route) => route.abort()); + await page.goto('./'); + + const banner = page.getByRole('alert'); + await expect(banner).toHaveCount(1); + await expect(banner).toContainText('Får ikke kontakt med serveren'); + await expect(banner).toContainText('NTNU VPN'); + // Radene gjentar ikke feilen; det er bare banneret som kunngjør den. + await expect(page.getByText('Kunne ikke hente denne raden.')).toHaveCount(0); + await expect(page.getByRole('article')).toHaveCount(0); + + await page.unroute(GRAPHQL); + await page.getByRole('button', { name: 'Prøv igjen' }).click(); + + await expect(page.getByRole('alert')).toHaveCount(0); + await expect( + page.getByRole('region', { name: 'Mest populære' }).getByRole('article').first(), + ).toBeVisible(); +}); + +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()); + await page.goto('./'); + await expect(page.getByRole('alert')).toHaveCount(1); + + await page.getByRole('button', { name: /Prøv igjen|Prøver/ }).click(); + await expect(page.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled(); + await expect(page.getByRole('alert')).toHaveCount(1); +}); + +for (const scheme of ['light', 'dark'] as const) { + test(`axe: forside med banner (${scheme})`, async ({ page }) => { + await page.emulateMedia({ colorScheme: scheme }); + await page.route(GRAPHQL, (route) => route.abort()); + await page.goto('./'); + await expect(page.getByRole('alert')).toContainText('NTNU VPN'); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + expect(results.violations.map((v) => `${v.id}: ${v.nodes.length} noder`)).toEqual([]); + }); +} diff --git a/frontend/package.json b/frontend/package.json index 9a787be..760a2bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,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", diff --git a/frontend/src/apollo/apiStatus.ts b/frontend/src/apollo/apiStatus.ts new file mode 100644 index 0000000..dc04958 --- /dev/null +++ b/frontend/src/apollo/apiStatus.ts @@ -0,0 +1,31 @@ +import { makeVar } from '@apollo/client'; +import { useReactiveVar } from '@apollo/client/react'; +import { CombinedGraphQLErrors, ServerError, ServerParseError } from '@apollo/client/errors'; + +/** + * É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. + */ +export const apiUnavailable = makeVar(false); + +export const useApiUnavailable = () => useReactiveVar(apiUnavailable); + +// Apache svarer med disse når Node-backenden eller proxyen er nede. +const GATEWAY_STATUSES = new Set([502, 503, 504]); + +/** + * Bare ekte nedetid teller. GraphQL-feil (validering, ugyldig input) betyr at serveren svarte og + * 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 (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; + // fetch() kaster TypeError ved nettverksfeil (DNS, VPN, blokkert, avbrutt). Enkelte nettlesere + // og tester bruker bare en Error med «Failed to fetch» som melding. + return ( + error instanceof TypeError || + (error instanceof Error && /failed to fetch|networkerror|load failed/i.test(error.message)) + ); +} diff --git a/frontend/src/apollo/client.test.ts b/frontend/src/apollo/client.test.ts index b480f65..07633b3 100644 --- a/frontend/src/apollo/client.test.ts +++ b/frontend/src/apollo/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GENRES_QUERY } from '../graphql/operations'; +import { apiUnavailable } from './apiStatus'; import { createClient } from './client'; import { getUserId, uuidV4 } from './userId'; @@ -64,3 +65,65 @@ describe('Apollo-klient', () => { expect((init.headers as Record)['x-user-id']).toBe(getUserId()); }); }); + +describe('API-status fra error-linken', () => { + const ok = () => + Response.json( + { data: { genres: ['Drama'] } }, + { headers: { 'content-type': 'application/json' } }, + ); + const noCache = { query: GENRES_QUERY, fetchPolicy: 'no-cache' } as const; + + beforeEach(() => { + localStorage.clear(); + apiUnavailable(false); + }); + afterEach(() => { + vi.unstubAllGlobals(); + apiUnavailable(false); + }); + + it('settes når fetch feiler', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); + await expect(createClient().query(noCache)).rejects.toThrow(); + expect(apiUnavailable()).toBe(true); + }); + + it.each([502, 503, 504])('settes ved HTTP %i fra proxyen', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('Bad gateway', { status })), + ); + await expect(createClient().query(noCache)).rejects.toThrow(); + expect(apiUnavailable()).toBe(true); + }); + + it('settes når svaret ikke er JSON', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('VPN-innlogging', { status: 200 })), + ); + await expect(createClient().query(noCache)).rejects.toThrow(); + expect(apiUnavailable()).toBe(true); + }); + + it.each([200, 400])('settes ikke ved GraphQL-feil med HTTP %i', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ errors: [{ message: 'Ugyldig' }] }, { status })), + ); + await expect(createClient().query(noCache)).rejects.toThrow(); + expect(apiUnavailable()).toBe(false); + }); + + it('nullstilles når et kall lykkes', async () => { + const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')); + fetchMock.mockImplementation(async () => ok()); + vi.stubGlobal('fetch', fetchMock); + const client = createClient(); + await expect(client.query(noCache)).rejects.toThrow(); + expect(apiUnavailable()).toBe(true); + await client.query(noCache); + expect(apiUnavailable()).toBe(false); + }); +}); diff --git a/frontend/src/apollo/client.ts b/frontend/src/apollo/client.ts index 62e382d..85d8518 100644 --- a/frontend/src/apollo/client.ts +++ b/frontend/src/apollo/client.ts @@ -1,6 +1,9 @@ import { ApolloClient, HttpLink } from '@apollo/client'; import { ApolloLink } from '@apollo/client'; import { SetContextLink } from '@apollo/client/link/context'; +import { tap } from 'rxjs'; +import { ErrorLink } from '@apollo/client/link/error'; +import { apiUnavailable, isNetworkDown } from './apiStatus'; import { createCache } from './cache'; import { getUserId } from './userId'; @@ -11,9 +14,24 @@ const userIdLink = new SetContextLink((prev) => ({ headers: { ...prev.headers, 'x-user-id': getUserId() }, })); +// Setter tilstanden ved nettverksfeil og nullstiller ved første svar fra serveren (også et +// GraphQL-feilsvar: da svarte den). I linkkjeden slik at alle queries og mutations dekkes. +const apiStatusLink = ApolloLink.from([ + new ErrorLink(({ error }) => { + if (isNetworkDown(error)) apiUnavailable(true); + }), + new ApolloLink((operation, forward) => + forward(operation).pipe( + tap(() => { + if (apiUnavailable()) apiUnavailable(false); + }), + ), + ), +]); + export function createClient() { return new ApolloClient({ cache: createCache(), - link: ApolloLink.from([userIdLink, new HttpLink({ uri })]), + link: ApolloLink.from([userIdLink, apiStatusLink, new HttpLink({ uri })]), }); } diff --git a/frontend/src/components/ApiUnavailableBanner.test.tsx b/frontend/src/components/ApiUnavailableBanner.test.tsx new file mode 100644 index 0000000..8602c80 --- /dev/null +++ b/frontend/src/components/ApiUnavailableBanner.test.tsx @@ -0,0 +1,56 @@ +import { ApolloProvider } from '@apollo/client/react'; +import type { ApolloClient } from '@apollo/client'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { apiUnavailable } from '../apollo/apiStatus'; +import { ApiUnavailableBanner } from './ApiUnavailableBanner'; + +function setup(refetchQueries: () => Promise) { + const client = { refetchQueries: vi.fn(refetchQueries) } as unknown as ApolloClient; + render( + + + , + ); + return client; +} + +afterEach(() => act(() => void apiUnavailable(false))); + +describe('ApiUnavailableBanner', () => { + it('vises ikke når API-et er tilgjengelig', () => { + setup(async () => []); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('forklarer nedetiden og nevner NTNU VPN', () => { + apiUnavailable(true); + setup(async () => []); + expect(screen.getByRole('alert')).toHaveTextContent('Får ikke kontakt med serveren'); + expect(screen.getByRole('alert')).toHaveTextContent('NTNU VPN'); + expect(screen.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled(); + }); + + it('«Prøv igjen» refetcher aktive queries og viser at den jobber', async () => { + apiUnavailable(true); + let finish!: () => void; + const client = setup(() => new Promise((resolve) => (finish = () => resolve([])))); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + expect(client.refetchQueries).toHaveBeenCalledWith({ include: 'active' }); + const busy = screen.getByRole('button', { name: 'Prøver …' }); + expect(busy).toBeDisabled(); + // Knappen ligger utenfor alert-regionen, så tekstskiftet kunngjøres ikke på nytt. + expect(screen.getByRole('alert')).not.toContainElement(busy); + await act(async () => finish()); + expect(screen.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled(); + }); + + it('tåler at refetch feiler', async () => { + apiUnavailable(true); + setup(() => Promise.reject(new TypeError('Failed to fetch'))); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + expect(await screen.findByRole('button', { name: 'Prøv igjen' })).toBeEnabled(); + }); +}); diff --git a/frontend/src/components/ApiUnavailableBanner.tsx b/frontend/src/components/ApiUnavailableBanner.tsx new file mode 100644 index 0000000..5362431 --- /dev/null +++ b/frontend/src/components/ApiUnavailableBanner.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { useApolloClient } from '@apollo/client/react'; +import { useApiUnavailable } from '../apollo/apiStatus'; + +/** + * Ett felles banner i stedet for én feil per rad. Selve meldingen ligger i role="alert" og + * knappen utenfor, slik at tekstskiftet «Prøver …» ikke kunngjøres på nytt. + */ +export function ApiUnavailableBanner() { + const unavailable = useApiUnavailable(); + const client = useApolloClient(); + const [retrying, setRetrying] = useState(false); + + if (!unavailable) return null; + + const retry = async () => { + setRetrying(true); + try { + await client.refetchQueries({ include: 'active' }); + } catch { + // Banneret blir stående hvis det fortsatt feiler; ingenting mer å gjøre her. + } finally { + setRetrying(false); + } + }; + + return ( +
+
+
+

+ Får ikke kontakt med serveren. +

+

+ Appen kjører på NTNUs nett. Er du utenfor campus, må du koble til NTNU VPN og prøve + igjen. +

+
+ +
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3661ac8..f819007 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { Suspense, useEffect, useRef, type MouseEvent } from 'react'; import { Link, NavLink, Outlet, ScrollRestoration, useLocation } from 'react-router'; import { useScrolled } from '../hooks/useScrolled'; +import { ApiUnavailableBanner } from './ApiUnavailableBanner'; import { HeaderSearch } from './HeaderSearch'; export function Layout() { @@ -50,6 +51,7 @@ export function Layout() {
+
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 && (
)} - {!titles && !error && } + {!titles && (!error || apiDown) && } {titles && (
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({ + return createYoga({ schema, graphqlEndpoint: '/graphql', healthCheckEndpoint: '/__yoga-health', plugins: [health, limits], - context: ({ request }) => - createContext(pool, request, artworkService, archiveUrl ?? config.archiveUrl, limiter), + // `req` finnes bare når Yoga kjører under node:http (ikke i yoga.fetch i tester). + context: ({ request, req }) => + createContext( + pool, + request, + artworkService, + archiveUrl ?? config.archiveUrl, + limiter, + req?.socket?.remoteAddress, + ), logging: process.env.NODE_ENV !== 'test', graphiql: !isProd, landingPage: !isProd, diff --git a/backend/src/complexityLimit.test.ts b/backend/src/complexityLimit.test.ts index 8bce5f6..33302cf 100644 --- a/backend/src/complexityLimit.test.ts +++ b/backend/src/complexityLimit.test.ts @@ -3,11 +3,11 @@ 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 } + type Node { id: ID, name: String, child: Node, reviews(first: Int): Node } + type Query { root: Node, search(first: Int): Node, myList(first: Int): Node } `); -const check = (q: string, maxRootFields = 3, maxFields = 10) => - validate(schema, parse(q), [complexityLimit({ maxRootFields, maxFields })]); +const check = (q: string, maxRootFields = 3, maxFields = 10, maxCost = 1e9) => + validate(schema, parse(q), [complexityLimit({ maxRootFields, maxFields, maxCost })]); const aliases = (n: number) => Array.from({ length: n }, (_, i) => `a${i}: root { id }`).join(' '); @@ -64,4 +64,65 @@ describe('complexityLimit', () => { check('{ root { ...A } } fragment A on Node { child { ...B } } fragment B on Node { ...A }'), ).not.toThrow(); }); + + it('tåler eksponentielt voksende fragmentkjeder spredt fra rotnivå (CPU-DoS)', () => { + // 636 byte i originalrapporten: 8 nivåer med ti spredninger hver tok ca. 60 s uten memoisering. + const levels = 8; + let doc = '{ ...F0 }'; + for (let i = 0; i < levels; i++) { + const uses = Array.from({ length: 10 }, () => `...F${i + 1}`).join(' '); + doc += ` fragment F${i} on Query { ${uses} }`; + } + doc += ` fragment F${levels} on Query { root { id } }`; + const started = Date.now(); + const errors = check(doc, 8, 1000); + expect(Date.now() - started).toBeLessThan(200); + // 10^8 rotfelt: må avvises, og med rotfelt-meldingen. + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('rotfelt'); + }); + + describe('vektet kostnad (first)', () => { + const cost = (q: string, max: number) => check(q, 100, 1000, max); + + it('vekter feltene under en liste med literal first', () => { + // search(1) + 50 x (id + name = 2) = 101 + const q = '{ search(first: 50) { id name } }'; + expect(cost(q, 101)).toHaveLength(0); + const errors = cost(q, 100); + expect(errors).toHaveLength(1); + expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT'); + expect(errors[0]?.message).toContain('first'); + }); + + it('bruker standard sidestørrelse når first utelates (search: 20, reviews: 10)', () => { + expect(cost('{ search { id } }', 21)).toHaveLength(0); + expect(cost('{ search { id } }', 20)).toHaveLength(1); + // search(1) + 20 x (reviews(1) + 10 x id) = 1 + 20 x 11 = 221 + expect(cost('{ search { reviews { id } } }', 221)).toHaveLength(0); + expect(cost('{ search { reviews { id } } }', 220)).toHaveLength(1); + }); + + it('regner en variabel first som 50, med mindre den har standardverdi', () => { + expect(cost('query($n: Int) { search(first: $n) { id } }', 51)).toHaveLength(0); + expect(cost('query($n: Int) { search(first: $n) { id } }', 50)).toHaveLength(1); + expect(cost('query($n: Int = 5) { search(first: $n) { id } }', 6)).toHaveLength(0); + }); + + it('multipliserer nøstede lister og avviser 8 x search(first: 50) med reviews(first: 50)', () => { + const one = 'search(first: 50) { id reviews(first: 50) { id name } }'; + const q = `{ ${Array.from({ length: 8 }, (_, i) => `s${i}: ${one}`).join(' ')} }`; + // Under rotfelt- og totalgrensene, men 8 x 50 x 50 x ... er enormt. + const errors = check(q, 8, 1000, 2500); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('kostbar'); + }); + + it('teller listevekt gjennom fragmenter', () => { + const q = '{ search(first: 10) { ...F } } fragment F on Node { id name }'; + // 1 + 10 x 2 = 21 + expect(cost(q, 21)).toHaveLength(0); + expect(cost(q, 20)).toHaveLength(1); + }); + }); }); diff --git a/backend/src/complexityLimit.ts b/backend/src/complexityLimit.ts index 3b625f1..c8c5e2f 100644 --- a/backend/src/complexityLimit.ts +++ b/backend/src/complexityLimit.ts @@ -1,7 +1,10 @@ import { GraphQLError, Kind, + type ArgumentNode, + type FieldNode, type FragmentDefinitionNode, + type OperationDefinitionNode, type SelectionSetNode, type ValidationRule, } from 'graphql'; @@ -11,18 +14,37 @@ export interface ComplexityLimits { maxRootFields: number; /** Maks antall felt totalt i én operasjon, fragmenter ekspandert og aliaser talt hver for seg. */ maxFields: number; + /** Maks vektet kostnad: feltene under en liste teller `first` ganger (se `LIST_FIELDS`). */ + maxCost: number; } +/** + * Listefeltene (Relay-forbindelser) og standard sidestørrelse når `first` utelates. Navnene er + * hardkodet i stedet for å slås opp i skjemaet for å holde regelen enkel; nye forbindelser må + * legges til her. + */ +export const LIST_FIELDS: Record = { search: 20, myList: 20, reviews: 10 }; +/** Tak for `first` (samme som MAX_FIRST i validation.ts), brukt når `first` er en variabel. */ +const MAX_FIRST_ASSUMED = 50; + /** * 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). + * (hver gir en egen SQL-spørring) eller `posterUrl`/`overview` (TMDB-oppslag) er grunn, men dyr, + * og `first: 50` på nøstede lister multipliserer arbeidet. Regelen teller derfor tre ting: + * - rotfelt (det er dem som starter databasekall), + * - alle felt totalt (størrelsen på svaret og antall resolver-kall), + * - vektet kostnad: et listefelt koster `first` ganger feltene under seg. En variabel `first` + * regnes som 50 (verste tilfelle) siden verdien ikke er kjent ved validering. Det overvurderer + * litt (totalCount og pageInfo telles også ganger `first`), som er trygt. * - * 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. + * Grensene er satt godt over de største spørringene frontend sender (se operations.ts og + * vaktbikkjetestene i test/server.test.ts), slik at vanlig bruk aldri kommer i nærheten. */ -export function complexityLimit({ maxRootFields, maxFields }: ComplexityLimits): ValidationRule { +export function complexityLimit({ + maxRootFields, + maxFields, + maxCost, +}: ComplexityLimits): ValidationRule { return (context) => ({ OperationDefinition(operation) { const fragments = new Map(); @@ -30,76 +52,98 @@ export function complexityLimit({ maxRootFields, maxFields }: ComplexityLimits): 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; - } + // Alt memoiseres per fragment. Uten det kan en kjede der hvert nivå spres ti ganger i det + // neste (F0 -> 10 x F1 -> 10 x F2 ...) gi eksponentielt mange besøk, og dermed CPU-DoS under + // selve valideringen (et dokument på under 1 kB tok minutter). + interface Counts { + roots: number; + fields: number; + cost: number; + } + const memo = new Map(); + const visitFragment = (name: string, visiting: Set): Counts | null => { + 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)) return null; + let c = memo.get(name); + if (!c) { + visiting.add(name); + c = count(frag.selectionSet, visiting); + visiting.delete(name); + memo.set(name, c); } - return n; + return c; }; - const rootFields = (set: SelectionSetNode, visiting: Set): number => { - let n = 0; + const count = (set: SelectionSetNode, visiting: Set): Counts => { + const total: Counts = { roots: 0, fields: 0, cost: 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); + // Introspeksjon har egen regel (slått av i produksjon) og er stor av natur. + if (sel.name.value === '__schema' || sel.name.value === '__type') continue; + const sub = sel.selectionSet ? count(sel.selectionSet, visiting) : null; + const mult = sub ? multiplier(sel, operation) : 1; + total.roots += 1; + total.fields += 1 + (sub?.fields ?? 0); + total.cost += 1 + mult * (sub?.cost ?? 0); } 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); + const c = + sel.kind === Kind.INLINE_FRAGMENT + ? count(sel.selectionSet, visiting) + : visitFragment(sel.name.value, visiting); + if (!c) continue; + total.roots += c.roots; + total.fields += c.fields; + total.cost += c.cost; } } - return n; + return total; }; - const roots = rootFields(operation.selectionSet, new Set()); - if (roots > maxRootFields) { + const { roots, fields, cost } = count(operation.selectionSet, new Set()); + const fail = (message: string) => 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' } }, - ), + new GraphQLError(message, { + 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' } }, - ), + if (roots > maxRootFields) { + fail( + `Spørringen har for mange rotfelt (${roots}); maks tillatt er ${maxRootFields}. ` + + 'Del den opp i flere forespørsler.', + ); + } else if (fields > maxFields) { + fail( + `Spørringen er for omfattende (${fields} felt); maks tillatt er ${maxFields}. ` + + 'Be om færre felt eller del den opp.', + ); + } else if (cost > maxCost) { + fail( + `Spørringen er for kostbar (${Math.round(cost)}); maks tillatt er ${maxCost}. ` + + 'Be om færre elementer per side (first) eller færre felt.', ); } }, }); } + +/** Hvor mange ganger feltene under et listefelt kan gjentas. 1 for alt som ikke er en liste. */ +function multiplier(field: FieldNode, operation: OperationDefinitionNode): number { + const fallback = LIST_FIELDS[field.name.value]; + if (fallback === undefined) return 1; + const first = field.arguments?.find((a: ArgumentNode) => a.name.value === 'first'); + if (!first) return fallback; + if (first.value.kind === Kind.INT) return Math.max(1, Number.parseInt(first.value.value, 10)); + if (first.value.kind === Kind.VARIABLE) { + // Bruk variabelens standardverdi hvis den har en; ellers verste tilfelle. + const def = operation.variableDefinitions?.find( + (v) => v.variable.name.value === (first.value as { name: { value: string } }).name.value, + ); + if (def?.defaultValue?.kind === Kind.INT) { + return Math.max(1, Number.parseInt(def.defaultValue.value, 10)); + } + return MAX_FIRST_ASSUMED; + } + return MAX_FIRST_ASSUMED; +} diff --git a/backend/src/config.ts b/backend/src/config.ts index bb91b09..d81ea22 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -31,6 +31,10 @@ function int(value: string | undefined, fallback: number): number { export const config = { port: int(process.env.PORT, 3001), + // I produksjon står Apache foran og er eneste vei inn; å lytte på loopback hindrer at noen når + // port 3001 direkte og dermed omgår proxyen (og IP-baserte grenser som stoler på den). + // Utvikling og tester lytter på alle grensesnitt som før. HOST overstyrer begge deler. + host: process.env.HOST || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : undefined), databaseUrl: process.env.DATABASE_URL || 'postgres://postgres@localhost:5432/project2', testDatabaseUrl: process.env.TEST_DATABASE_URL || 'postgres://postgres@localhost:5432/project2_test', diff --git a/backend/src/context.ts b/backend/src/context.ts index 756a52d..3cc5079 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -34,12 +34,13 @@ export function createContext( artwork: ArtworkService, archiveUrl: string = config.archiveUrl, limiter: MutationLimiter = new MutationLimiter(), + remoteAddress?: string, ): Context { const userId = parseUserId(request.headers.get('x-user-id')); return { pool, userId, - ip: clientIp(request), + ip: clientIp(request, remoteAddress), limiter, artwork, loaders: { diff --git a/backend/src/depthLimit.test.ts b/backend/src/depthLimit.test.ts index 7d61258..1ec06dc 100644 --- a/backend/src/depthLimit.test.ts +++ b/backend/src/depthLimit.test.ts @@ -28,4 +28,17 @@ describe('depthLimit', () => { it('hopper over introspeksjon', () => { expect(check('{ __schema { types { fields { type { ofType { name } } } } } }')).toHaveLength(0); }); + it('tåler eksponentielt voksende fragmentkjeder uten å henge (CPU-DoS)', () => { + 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 { child { child { child { id } } } }`; + const started = Date.now(); + expect(check(doc)).toHaveLength(1); // dybde 1 + 3 + 1 > 3 + expect(Date.now() - started).toBeLessThan(200); + expect(check(doc.replace('child { child { child { id } } }', 'id'))).toHaveLength(0); + }); }); diff --git a/backend/src/depthLimit.ts b/backend/src/depthLimit.ts index 218a914..b1bec03 100644 --- a/backend/src/depthLimit.ts +++ b/backend/src/depthLimit.ts @@ -20,29 +20,38 @@ export function depthLimit(max: number): ValidationRule { if (def.kind === Kind.FRAGMENT_DEFINITION) fragments.set(def.name.value, def); } - const depthOf = (set: SelectionSetNode, level: number, visiting: Set): number => { - let deepest = level; + // Dybden regnes relativt til utvalget og memoiseres per fragment. Uten memoisering kan en + // fragmentkjede der hvert nivå spres ti ganger i det neste (F0 -> 10 x F1 -> ...) gi + // eksponentielt mange besøk, og dermed CPU-DoS under selve valideringen. + const memo = new Map(); + const heightOf = (set: SelectionSetNode, visiting: Set): number => { + let deepest = 0; for (const sel of set.selections) { if (sel.kind === Kind.FIELD) { if (sel.name.value === '__schema' || sel.name.value === '__type') continue; - const d = sel.selectionSet ? depthOf(sel.selectionSet, level + 1, visiting) : level; + const d = sel.selectionSet ? 1 + heightOf(sel.selectionSet, visiting) : 0; deepest = Math.max(deepest, d); } else if (sel.kind === Kind.INLINE_FRAGMENT) { - deepest = Math.max(deepest, depthOf(sel.selectionSet, level, visiting)); + deepest = Math.max(deepest, heightOf(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; - visiting.add(name); - deepest = Math.max(deepest, depthOf(frag.selectionSet, level, visiting)); - visiting.delete(name); + let h = memo.get(name); + if (h === undefined) { + visiting.add(name); + h = heightOf(frag.selectionSet, visiting); + visiting.delete(name); + memo.set(name, h); + } + deepest = Math.max(deepest, h); } } return deepest; }; - const depth = depthOf(operation.selectionSet, 0, new Set()); + const depth = heightOf(operation.selectionSet, new Set()); if (depth > max) { context.reportError( new GraphQLError(`Spørringen er for dyp (${depth}); maks tillatt dybde er ${max}.`, { diff --git a/backend/src/index.ts b/backend/src/index.ts index b010f55..724697e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,8 +11,8 @@ const yoga = createApp({ }); const server = createServer(yoga); -server.listen(config.port, () => { - console.log(`GraphQL-API på http://localhost:${config.port}/graphql`); +server.listen(config.port, config.host, () => { + console.log(`GraphQL-API på http://${config.host ?? 'localhost'}:${config.port}/graphql`); }); // Rydder opp så tilkoblinger lukkes pent (systemd sender SIGTERM, Ctrl+C sender SIGINT). diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts index ae7d4c9..71d51f7 100644 --- a/backend/src/rateLimit.ts +++ b/backend/src/rateLimit.ts @@ -96,7 +96,10 @@ export class MutationLimiter { /** 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. + // Rekkefølge: IP først, og `||` stopper ved første avvisning. Avvises IP-en, rører vi ikke + // brukerkvoten. Omvendt har et IP-token allerede blitt brukt når brukerkvoten avviser. Det er + // bevisst og greit: forespørselen kom fra den IP-en uansett, så en bruker som spammer skal + // også telle mot IP-ens kvote (og dermed til slutt stoppes på IP selv om hun bytter bruker-id). 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); @@ -104,12 +107,23 @@ export class MutationLimiter { } } +/** 127.0.0.0/8, ::1 og IPv4-mappede varianter (::ffff:127.0.0.1). */ +export function isLoopback(address: string): boolean { + return /^(::ffff:)?127\./i.test(address) || address === '::1'; +} + /** * 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. + * ikke noe klienten kan forfalske (tidligere ledd kan). Headeren stoles BARE på når selve + * TCP-forbindelsen kommer fra loopback, dvs. fra proxyen på samme maskin. Kommer forbindelsen fra + * en annen adresse (noen har nådd port 3001 direkte), brukes den adressen og headeren ignoreres, + * ellers kunne klienten satt en ny falsk IP i hver forespørsel og omgått IP-grensen. + * + * `remoteAddress` er undefined når appen kalles uten node:http (tester via yoga.fetch); da finnes + * ingen forbindelse å sjekke, og headeren brukes som den er. Uten header begrenses bare per bruker. */ -export function clientIp(request: Request): string | null { +export function clientIp(request: Request, remoteAddress?: string): string | null { + if (remoteAddress && !isLoopback(remoteAddress)) return remoteAddress.slice(0, 64); const header = request.headers.get('x-forwarded-for'); if (!header) return null; const last = header.split(',').at(-1)?.trim(); diff --git a/backend/src/typeDefs.ts b/backend/src/typeDefs.ts index 15efd8d..ac5892a 100644 --- a/backend/src/typeDefs.ts +++ b/backend/src/typeDefs.ts @@ -126,7 +126,7 @@ export const typeDefs = /* GraphQL */ ` type Query { # 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. + # i primær- og originaltittel. Søk på 1–2 tegn er ordprefiks («ma» finner «The Matrix») og rangeres etter popularitet. search( query: String filters: SearchFilters diff --git a/backend/test/rate-limit.test.ts b/backend/test/rate-limit.test.ts index 21a73ec..fc7cdba 100644 --- a/backend/test/rate-limit.test.ts +++ b/backend/test/rate-limit.test.ts @@ -3,7 +3,7 @@ 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 { MutationLimiter, TokenBuckets, clientIp, isLoopback } from '../src/rateLimit.js'; import { resetData } from './fixtures.js'; import { USER_A, USER_B } from './helpers.js'; @@ -32,6 +32,26 @@ describe('TokenBuckets', () => { }); }); +describe('clientIp med forbindelsesadresse', () => { + const req = (h: string) => new Request('http://x/', { headers: { 'x-forwarded-for': h } }); + it('stoler på X-Forwarded-For bare når forbindelsen kommer fra loopback (proxyen)', () => { + for (const proxy of ['127.0.0.1', '127.1.2.3', '::1', '::ffff:127.0.0.1']) { + expect(clientIp(req('6.6.6.6, 203.0.113.9'), proxy)).toBe('203.0.113.9'); + } + }); + it('bruker forbindelsens adresse og ignorerer headeren ellers (port 3001 nådd direkte)', () => { + expect(clientIp(req('1.2.3.4'), '203.0.113.50')).toBe('203.0.113.50'); + expect(clientIp(req('1.2.3.4'), '2001:db8::1')).toBe('2001:db8::1'); + // Uten header og fra utsiden: forbindelsen er likevel kjent. + expect(clientIp(new Request('http://x/'), '203.0.113.50')).toBe('203.0.113.50'); + }); + it('isLoopback gjenkjenner ikke tilsvarende utenfor 127/8', () => { + expect(isLoopback('128.0.0.1')).toBe(false); + expect(isLoopback('10.0.0.1')).toBe(false); + expect(isLoopback('::2')).toBe(false); + }); +}); + describe('clientIp', () => { const ip = (h?: string) => clientIp(new Request('http://x/', h ? { headers: { 'x-forwarded-for': h } } : {})); @@ -78,12 +98,18 @@ describe('begrensning av mutations via API-et', () => { query: string, user: string, headers: Record = {}, + remoteAddress?: string, ) => { - const res = await yoga.fetch('http://localhost/graphql', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-user-id': user, ...headers }, - body: JSON.stringify({ query }), - }); + const res = await yoga.fetch( + 'http://localhost/graphql', + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user-id': user, ...headers }, + body: JSON.stringify({ query }), + }, + // Det node:http gir Yoga som `req`; uten det (som i yoga.fetch) finnes ingen forbindelse. + remoteAddress ? { req: { socket: { remoteAddress } } } : {}, + ); return (await res.json()) as { data?: any; errors?: { message: string; extensions: any }[] }; }; const add = (rating = 4) => @@ -149,6 +175,31 @@ describe('begrensning av mutations via API-et', () => { expect(other.errors).toBeUndefined(); }); + it('IP-grensen kan ikke omgås med falsk X-Forwarded-For når forbindelsen er direkte', async () => { + const yoga = createApp({ pool, rateLimits: { reviewsPerMinute: 1, ipFactor: 2 } }); + const uuid = (n: number) => `00000000-0000-4000-8000-0000000001${n}0`; + const outside = '203.0.113.50'; + // Ny falsk IP og ny bruker for hver forespørsel: tredje skal likevel avvises. + const r = []; + for (let i = 1; i <= 3; i++) { + r.push(await call(yoga, add(), uuid(i), { 'x-forwarded-for': `9.9.9.${i}` }, outside)); + } + expect(r[0]!.errors).toBeUndefined(); + expect(r[1]!.errors).toBeUndefined(); + expect(r[2]!.errors![0]!.extensions.code).toBe('RATE_LIMITED'); + // Fra proxyen (loopback) er headeren derimot gyldig, og ulike klienter er uavhengige. + for (let i = 4; i <= 6; i++) { + const res = await call( + yoga, + add(), + uuid(i), + { 'x-forwarded-for': `8.8.8.${i}` }, + '127.0.0.1', + ); + expect(res.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(); From ecabc500ea7b6afb72a1df05460f67d14d2fde2d Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:27:59 +0000 Subject: [PATCH 015/132] docs: measurements, lock window for migration 004 and decisions for review fixes (#15) --- deploy/setup-vm.sh | 4 ++ docs/api.md | 4 +- docs/beslutninger.md | 32 +++++++++---- docs/deploy.md | 19 +++++++- docs/status.md | 2 +- docs/ytelse.md | 109 ++++++++++++++++++++++--------------------- 6 files changed, 104 insertions(+), 66 deletions(-) diff --git a/deploy/setup-vm.sh b/deploy/setup-vm.sh index 1a62481..bfa77ca 100755 --- a/deploy/setup-vm.sh +++ b/deploy/setup-vm.sh @@ -85,6 +85,10 @@ sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm; step "Installerer avhengigheter og bygger" HUSKY=0 npm ci --no-audit --no-fund npm run build +# Migrering 004 skriver om titles og holder en lås som blokkerer all lesing og skriving i noen sekunder (ca. 4 s lokalt, 10–20 s på VM). +# Stopper backend først (ved første kjøring finnes den ikke ennå), så brukere får en ryddig 503 fra +# Apache i stedet for forespørsler som henger på låsen. Den startes igjen lenger ned. +sudo systemctl stop project2-backend 2>/dev/null || true npm run db:migrate step "Laster ned IMDb-data og importerer (tar noen minutter)" diff --git a/docs/api.md b/docs/api.md index 7e3c1a0..358bf42 100644 --- a/docs/api.md +++ b/docs/api.md @@ -135,7 +135,7 @@ type DeleteReviewPayload { type Query { # 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. + # i primær- og originaltittel. Søk på 1–2 tegn er ordprefiks («ma» finner «The Matrix») og rangeres etter popularitet. search( query: String filters: SearchFilters @@ -167,7 +167,7 @@ type Mutation { `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. + ekspandert), ellers `BAD_USER_INPUT`. Dessuten en vektet kostnad maks 2 500, der feltene under `search`, `myList` og `reviews` teller `first` ganger (standard 20/20/10; variabel `first` regnes som 50). Frontendens største spørring har ca. 45 felt og kostnad ca. 1 100. - `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 diff --git a/docs/beslutninger.md b/docs/beslutninger.md index c643798..6a85dbf 100644 --- a/docs/beslutninger.md +++ b/docs/beslutninger.md @@ -209,15 +209,31 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. `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 +- **Korte søk (1–2 tegn) er ORDprefiks 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. + alle treff tok ca. 0,3 s for «a». Nå brukes en generert `tsvector` (`title_words`, `'simple'`) med + GIN og `to_tsquery('simple', ':*')`; relevans er stemmer, så id. «ma» finner «The Matrix», + men «ar» finner ikke «Dark». Søketeksten strippes for tegnsetting før den settes inn i + tsquery-syntaksen, så operatortegn ikke kan gi syntaksfeil, og blir ingenting igjen gir det null + treff. Alternativene vi vurderte: (1) beholde delstreng og bare droppe likhetsberegningen (hindrer + ikke full skanning), (2) prefiks av hele tittelen med `text_pattern_ops`-btree, som vi først + implementerte og målte (like rask) men som ikke fant «The Matrix» på «ma». Den tidligere påstanden + om at ordprefiks krever regex uten indeks var feil: tsvector med GIN gir ordprefiks med indeks. + Cursoren har en egen signatur for korte søk, siden sorteringsnøklene er annerledes. +- **Søketeksten normaliseres før den escapes, begge deler i SQL.** `unaccent` mapper fullbreddetegn + (`%`, `_`, `\`) til `%`, `_` og `\`; escapet vi i JavaScript først, ble de jokertegn i LIKE og + traff hele tabellen. Mønsteret bygges derfor som `'%' || regexp_replace(lower(f_unaccent($1)), +'([\\%_])', '\\\1', 'g') || '%'`. +- **Kompleksitetsgrensen veier lister med `first`.** I tillegg til rotfelt og totalt antall felt har + `complexityLimit` en vektet kostnad: feltene under `search`, `myList` og `reviews` teller `first` + ganger (standard 20/20/10 når `first` utelates, 50 når `first` er en variabel uten + standardverdi). 8 x `search(first: 50) { reviews(first: 50) }` er under feltgrensene, men koster + millioner og avvises. Grensen er 2 500; frontendens største spørring koster ca. 1 100. + Regelen er memoisert per fragment (også `depthLimit`), siden en kjede av fragmenter som hver + spres ti ganger i det neste ellers tok minutter CPU under validering. +- **Backend lytter på `127.0.0.1` i produksjon, og `X-Forwarded-For` stoles bare på fra loopback.** + Uten det kunne noen nå port 3001 direkte og sette en ny falsk IP per forespørsel og dermed omgå + IP-grensen. `HOST` kan overstyre. Apache og `deploy/` bruker allerede `127.0.0.1`. ## Feiltilstand når API-et ikke nås (#13) diff --git a/docs/deploy.md b/docs/deploy.md index ce910f7..4013567 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -69,7 +69,22 @@ sudo apachectl configtest && sudo systemctl reload apache2 ## Oppdatering senere ```bash -cd /opt/project2 && git pull && npm ci && npm run build && npm run db:migrate +cd /opt/project2 && git pull && npm ci && npm run build +sudo systemctl stop project2-backend # se «Låsvindu» under +npm run db:migrate sudo cp -r frontend/dist/* /var/www/html/project2/ -sudo systemctl restart project2-backend +sudo systemctl start project2-backend ``` + +**Låsvindu for migrering 004.** Migreringen legger til tre genererte (`STORED`) kolonner på `titles` +(`primary_title_norm`, `original_title_norm`, `title_words`) i én `ALTER TABLE`, og bygger deretter +tre GIN-indekser på dem. `ALTER TABLE ... ADD COLUMN ... STORED` skriver om hele tabellen mens den +holder `ACCESS EXCLUSIVE`-lås, så alle spørringer mot `titles` står i kø til den er ferdig (ca. 4 s lokalt, regn med 10–20 s +for 120 000 titler på VM-en; indeksbyggingen kommer i tillegg). Kjør derfor migreringen med backend +stoppet, slik `deploy/setup-vm.sh` og kommandoene over gjør. Senere migreringer som ikke skriver om +tabellen trenger ikke dette. + +**Nettverk.** Med `NODE_ENV=production` (satt i `deploy/project2-backend.service`) lytter backend bare +på `127.0.0.1:3001`, så port 3001 ikke kan nås utenfra uten om Apache. `HOST` overstyrer ved behov. +Apache-konfigen og `deploy/sjekk.sh` bruker allerede `127.0.0.1`. Begrensningen av mutations stoler på +`X-Forwarded-For` bare når forbindelsen kommer fra loopback. diff --git a/docs/status.md b/docs/status.md index a3b56ea..5baccc3 100644 --- a/docs/status.md +++ b/docs/status.md @@ -37,7 +37,7 @@ Alle punktene i `PLAN.md` er gjort og verifisert i utviklingsmiljøet. ## Kjente begrensninger -- Søk på 1–2 tegn er prefiksmatch på tittelen («th» finner «The Matrix», men ikke «Death»), og +- Søk på 1–2 tegn er ordprefiks («th» finner «The Matrix», men ikke «Death»; tegnsetting ignoreres), 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. diff --git a/docs/ytelse.md b/docs/ytelse.md index 9a1ae85..24ca690 100644 --- a/docs/ytelse.md +++ b/docs/ytelse.md @@ -80,29 +80,30 @@ 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. +Migrering `004_unaccent_search.sql` gjør søket aksentuavhengig og endrer 1–2 tegns søk til +ORDprefiks («ma» finner «The Matrix»). 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 for `totalCount`, og tabellen er gjennomsnittet av to hele måleomganger. «Før» er +koden og indeksene fra før migreringen, «etter» er samme database etter migreringen. > **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. +> forskjeller på under ca. 20 % bør leses som «omtrent lik». Størrelsesforholdene er reproduserbare +> på tvers av kjøringer; absolutt-tidene er det ikke. Datasettet er syntetisk, så hvor mange titler +> som har et ord 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 | +| «a» (relevans), 1 tegn | 314,0 | 0,18 | 46,1 | 11,5 | +| «a» (rating) | 0,05 | 0,15 | 43,3 | 12,2 | +| «ma» (relevans), 2 tegn | 83,7 | 0,31 | 48,8 | 8,6 | +| «ma» (rating) | 0,6 | 0,35 | 48,0 | 10,1 | +| «br» (relevans), 2 tegn | 78,7 | 0,24 | 50,1 | 7,8 | +| «th» (relevans), 2 tegn | 195,3 | 0,052 | 44,8 | 16,8 | +| «the» (relevans) | 210,1 | 142,1 | 30,8 | 22,9 | +| «dark» (relevans) | 21,7 | 16,9 | 3,4 | 2,6 | +| «dark» (rating) | 3,1 | 1,0 | 3,3 | 2,5 | +| «amelie» (relevans) | 0,05 (0 treff) | 18,5 | 0,05 (0 treff) | 2,9 | 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 @@ -113,38 +114,40 @@ søket 0 treff før, fordi aksenten ikke ble foldet; tallene er derfor ikke samm - **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. + på dem. Søketeksten normaliseres med samme uttrykk i SQL, og FØRST etter det escapes `%`, `_` og + `\` (unaccent mapper fullbreddetegn som `%` til `%`, så rekkefølgen er sikkerhetsrelevant). - **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): + Med genererte kolonner er alle tallene på høyde med eller bedre enn før. +- **Korte søk (1–2 tegn):** ORDprefiks mot en generert `tsvector` (`title_words`, `'simple'`-konfig + over normalisert primær- og originaltittel) med GIN-indeks. Søket er + `title_words @@ to_tsquery('simple', ':*')`, og relevans = popularitet (stemmer, så id) i + stedet for likhet. Før lå kostnaden i å regne `similarity`/`word_similarity` på nesten alle rader + (314 ms for «a»). Nå leser planleggeren de 21 første radene baklengs fra stemmeindeksen + (`Index Scan Backward using titles_votes_idx`, 0,03–0,4 ms), og `search-index.test.ts` verifiserer at + `titles_title_words_idx` kan brukes. Søketeksten strippes for tegnsetting før den settes inn i + tsquery-syntaksen, så `& | ! ( ) : * < > '` og `\` ikke kan gi syntaksfeil. +- **Hvorfor ikke prefiks av hele tittelen:** vi prøvde først `LIKE 'a%'` med `text_pattern_ops`-btree. + Det er raskt, men finner bare titler som BEGYNNER med søketeksten («ma» fant ikke «The Matrix»). + Ordprefiks med tsvector er like raskt og finner det brukere venter, og gjør de to btree-indeksene + overflødige, så de ble tatt ut av migreringen. + +Kostnad i plass og migreringstid (120 000 titler): heapen vokste fra 19 til 31 MB (tre genererte +kolonner), `titles_title_words_idx` er 1,3 MB, og trigramindeksene er omtrent like store som før +(6,0 og 6,3 MB). Migreringen tok ca. 3,8 s for `ALTER TABLE` (omskriving av tabellen, med +`ACCESS EXCLUSIVE`-lås) og ca. 1,2 s for de tre indeksene, lokalt. Regn med flere ganger så lenge på +VM-en; se «Låsvindu» i `docs/deploy.md`. + +Plan etter, «ma» (relevans), siden: ``` -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 +Limit (actual time=0.011..0.257 rows=21 loops=1) + -> Index Scan Backward using titles_votes_idx on titles t (actual time=0.010..0.255 rows=21 loops=1) + Filter: (title_words @@ '''ma'':*'::tsquery) + Rows Removed by Filter: 349 +Execution Time: 0.265 ms ``` Plan etter, «amelie» (relevans): `BitmapOr` av `titles_primary_title_trgm_idx` og @@ -153,16 +156,16 @@ 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 +- **«The» og andre svært vanlige ord er fortsatt tregt (ca. 140 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 10–25 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. +- **Korte søk er en funksjonell endring:** «ar» finner ikke «Dark» (ordprefiks, ikke delstreng), og + tegnsetting i søket ignoreres («a:» betyr «a»). 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»), 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. From ee71db038ff860525102db6763a3efd002a5c78d Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:29:17 +0000 Subject: [PATCH 016/132] fix(frontend): drop aria-pressed from list toggle with dynamic label (#16) --- docs/beslutninger.md | 4 ++++ frontend/src/components/ListToggleButton.tsx | 6 +++++- frontend/src/pages/TitlePage.test.tsx | 17 ++++++----------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/beslutninger.md b/docs/beslutninger.md index 2b3f3c5..17384fd 100644 --- a/docs/beslutninger.md +++ b/docs/beslutninger.md @@ -176,3 +176,7 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. - **É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 `Wants=postgresql.service` (+ `After`). + +## Gjennomgang av frontend (#16) + +- **«Legg i min liste»-knappen har dynamisk etikett og ingen `aria-pressed`.** Kombinasjonen ga motstridende opplesning («Fjern fra min liste, trykket»). Et fast navn («Min liste» + `aria-pressed`) ville stridd mot den synlige teksten (WCAG 2.5.3 Label in Name) og gjort det uklart for seende hva et trykk gjør. Dynamisk etikett er handlingsorientert for begge grupper; utfallet kunngjøres i `role="status"`. diff --git a/frontend/src/components/ListToggleButton.tsx b/frontend/src/components/ListToggleButton.tsx index d61aa8f..89dd226 100644 --- a/frontend/src/components/ListToggleButton.tsx +++ b/frontend/src/components/ListToggleButton.tsx @@ -9,6 +9,11 @@ interface Props { variant?: 'primary' | 'secondary'; } +/** + * Handlingsetikett som skifter («Legg i» / «Fjern fra min liste»), bevisst uten `aria-pressed`: + * en bryter med dynamisk navn leses opp motstridende («Fjern fra min liste, trykket»), og et + * fast navn ville stride mot den synlige teksten (WCAG 2.5.3). Resultatet kunngjøres i statusregionen. + */ export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Props) { const [toggleList, { loading }] = useMutation(TOGGLE_LIST_MUTATION); const [message, setMessage] = useState(''); @@ -31,7 +36,6 @@ export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Pro + ); +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..f344d52 --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useState } from 'react'; +import { LIGHT_QUERY, loadTheme, saveTheme, systemTheme, type Theme } from '../lib/theme'; + +/** + * Brukerens temavalg, ellers systemets. `data-theme` på settes bare etter et eksplisitt valg; + * uten det avgjør `prefers-color-scheme` i CSS, og et skifte i systemet følges også mens siden er åpen. + */ +export function useTheme() { + const [chosen, setChosen] = useState(loadTheme); + const [system, setSystem] = useState(systemTheme); + + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const query = window.matchMedia(LIGHT_QUERY); + const onChange = () => setSystem(query.matches ? 'light' : 'dark'); + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, []); + + const theme = chosen ?? system; + + const toggle = useCallback(() => { + const next: Theme = theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + saveTheme(next); + setChosen(next); + }, [theme]); + + return { theme, toggle }; +} diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..95e9bd7 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -0,0 +1,33 @@ +export type Theme = 'light' | 'dark'; + +/** Samme nøkkel som det innebygde skriptet i index.html (som setter temaet før React starter). */ +export const THEME_KEY = 'filmsok:theme'; + +const isTheme = (v: unknown): v is Theme => v === 'light' || v === 'dark'; + +/** localStorage kan kaste (blokkert av nettleseren) eller inneholde rusk; da følger vi systemet. */ +export function loadTheme(): Theme | null { + try { + const raw = localStorage.getItem(THEME_KEY); + return isTheme(raw) ? raw : null; + } catch { + return null; + } +} + +export function saveTheme(theme: Theme) { + try { + localStorage.setItem(THEME_KEY, theme); + } catch { + // Å huske temaet er en bekvemmelighet; valget gjelder uansett resten av økten. + } +} + +export const LIGHT_QUERY = '(prefers-color-scheme: light)'; + +/** Mørkt er standard i CSS; systemet gir lyst bare når nettleseren eksplisitt ber om det. */ +export function systemTheme(): Theme { + return typeof window.matchMedia === 'function' && window.matchMedia(LIGHT_QUERY).matches + ? 'light' + : 'dark'; +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 2ba37aa..f6b4f25 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1,5 +1,6 @@ /* - * Kinoaktig tema: mørkt er standard, lyst følger systemet (prefers-color-scheme: light). + * Kinoaktig tema: mørkt er standard, lyst følger systemet (prefers-color-scheme: light), og brukeren + * kan overstyre med knappen i headeren (data-theme på , satt også av et lite skript i index.html). * Alle fargepar er valgt for WCAG AA (tekst 4.5:1, kontroller 3:1) i begge temaer. * Header, heltebilder og plakat-overlegg er alltid mørke (--on-media), fordi de ligger over bilder. */ @@ -41,8 +42,10 @@ line-height: 1.5; } +/* De lyse verdiene må stå to ganger (systemvalg og eksplisitt valg); CSS kan ikke dele dem uten forprosessor. + Endres de ene, må de andre endres likt. Eksplisitt «dark» vinner over systemets lyse tema. */ @media (prefers-color-scheme: light) { - :root { + :root:not([data-theme='dark']) { --bg: #f5f4f1; --surface: #ffffff; --surface-2: #ebe9e4; @@ -59,6 +62,27 @@ } } +/* color-scheme styrer nettleserens egne flater (rullefelt, skjemakontroller) og må følge valget. */ +:root[data-theme='dark'] { + color-scheme: dark; +} +:root[data-theme='light'] { + color-scheme: light; + --bg: #f5f4f1; + --surface: #ffffff; + --surface-2: #ebe9e4; + --border: #6b7080; + --border-soft: #d7d4cc; + --text: #14151a; + --muted: #4a4f5d; + --accent-text: #7a5200; + --focus: #14151a; + --danger: #b00020; + --success: #0b6b2e; + --skeleton: #e4e1da; + --shadow: 0 0.75rem 1.75rem rgb(20 20 30 / 0.25); +} + @media (min-width: 48rem) { :root { --header-h: 4.25rem; @@ -233,6 +257,24 @@ main { box-shadow: inset 0 -3px 0 #f5c518; border-radius: 0; } +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + /* Rett etter menyen på smal skjerm (søkefeltet har order 3), sist i headeren på bred. */ + order: 2; + padding: 0; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--on-media-muted); + cursor: pointer; +} +.theme-toggle:hover { + color: var(--on-media); +} .site-header :focus-visible, .hero :focus-visible, .title-hero :focus-visible, @@ -254,6 +296,9 @@ main { .site-header nav + .searchbox { margin-left: auto; } + .theme-toggle { + order: 0; + } } .site-footer { From dd5a9f5293c2f414a37b84dbc258329d02ec74ce Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:40:34 +0000 Subject: [PATCH 024/132] docs: add AI declaration (draft) (#17) --- docs/ki-deklarasjon.md | 176 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/ki-deklarasjon.md diff --git a/docs/ki-deklarasjon.md b/docs/ki-deklarasjon.md new file mode 100644 index 0000000..131df21 --- /dev/null +++ b/docs/ki-deklarasjon.md @@ -0,0 +1,176 @@ +# KI-deklarasjon + +Denne fila beskriver hvordan gruppa har brukt kunstig intelligens (KI) i prosjekt 2, hva KI-en +har laget, hva mennesker har bestemt og kontrollert, og hvilke feil og begrensninger vi har sett. +Den utfyller [`ki-logg.md`](ki-logg.md) (én linje per oppgave) og +[`beslutninger.md`](beslutninger.md) (alle tekniske valg med begrunnelse). + +## Kort oppsummert + +- Prosjektet er utviklet med **Claude Code** (Anthropic) som KI-assistent i hele løpet: plan, + kode, tester, dokumentasjon og kodegjennomgang. +- **Det meste av koden er skrevet av KI-agenter.** Mennesker har bestemt mål, datasett, rammer og + prioriteringer, godkjent planer før arbeid startet, satt opp og driftet VM-en, og vurdert + tilbakemeldinger fra medstudenter. +- **Ingen KI-kode er tatt inn uten kontroll.** Hver endring har gått gjennom automatiske tester + (lint, typecheck, komponent-, API- og E2E-tester med axe) og en egen KI-kodegjennomgang før + merge. Fra oktober skjer dette synlig i issues og pull requests med review-kommentarer. +- Vi har ikke latt KI-en skjule feil: kjente feil, avvik og begrensninger står i + [Feil KI-en gjorde](#feil-ki-en-gjorde-og-hvordan-de-ble-oppdaget) og + [Begrensninger](#begrensninger-og-ting-vi-ikke-har-kunnet-verifisere). + +## Verktøy + +| Verktøy | Brukt til | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Claude Code (CLI og sky-økt) | All KI-assistert utvikling: planlegging, kode, tester, dokumentasjon, kodegjennomgang | +| Claude-modeller fra Anthropic | En kraftigere modell som «leder» og kritiker, en raskere modell for avgrensede utvikleroppgaver | +| GitHub (issues, PR, review) | Sporbar plan og kodegjennomgang fra oktober. Repoet flyttes til NTNU GitLab ved innlevering | +| Ingen andre KI-verktøy | Vi har ikke brukt KI til bilder, tekst i appen fra eksterne kilder eller generering av testdata ut over skriptet i `backend/scripts` | + +Agentenes roller og instrukser ligger i repoet, så de kan inspiseres: + +- [`CLAUDE.md`](../CLAUDE.md): regler for alle agenter (tekniske rammer, krav til testing, + tilgjengelighet, bærekraft, git-flyt og hva som krever et menneske). +- [`.claude/agents/`](../.claude/agents/): rollebeskrivelser for `backend-utvikler`, + `frontend-utvikler`, `tester` og `reviewer`. +- [`PLAN.md`](../PLAN.md): milepælene (M0–M6) som agentene jobbet mot, krysset av når verifisert. + +## Arbeidsflyt + +``` +Menneske: mål, rammer, godkjenning av plan + │ + ▼ +Leder-agent ──▶ issue per oppgave ──▶ utvikler-agent i egen git-worktree (egen gren) + ▲ │ + │ ▼ + │ tester lokalt (lint, typecheck, Vitest, Playwright + axe) + │ │ + │ ▼ + │ pull request ──▶ kritiker-agent: review på PR-en + │ │ │ + │ │◀── funn ───┘ (rettes med nye commits) + │ ▼ + └──────────────────── leder verifiserer, merger, lukker issue, oppdaterer logg +``` + +1. **Mennesket setter retningen.** Nicolay skrev `CLAUDE.md` med rammene (monorepo, PostgreSQL uten + Docker, søk/filter/sortering alltid i SQL, cursor-paginering, debounce 300 ms, WCAG 2.1 AA, + bærekraft, testkrav) og valgte datasett og mål. Planer fra KI-en ble lagt fram og **godkjent + eller avvist** før arbeid startet. Planen for ferdigstilling i oktober ble for eksempel + underkjent i første runde og justert to ganger før godkjenning. +2. **Leder-agenten deler opp arbeidet.** Den skriver ikke produksjonskode selv, men lager issues + med tydelige ferdigkriterier og delegerer til utvikler-agenter. +3. **Utvikler-agenter jobber isolert.** Hver oppgave kjøres i en egen git-worktree og gren, slik at + parallelle oppgaver ikke ødelegger for hverandre. +4. **Kritiker-agenten gjennomgår hver PR** og poster funn som review-kommentarer, rangert som + blokkerende, bør fikses eller valgfritt. Funn rettes med nye commits (ingen omskriving av + historikk), og kritikeren gjør en ny runde ved større endringer. +5. **Merge først når alt er grønt.** Leder kjører testene selv, svarer på review-trådene, merger og + lukker issue. +6. **Målløkke.** Til slutt vurderte kritikeren hele appen opp mot vurderingskriteriene i + oppgaveteksten, og funnene ble nye issues (#15–#17) som gikk gjennom samme løkke. + +## Hva KI-en har laget + +- **Backend:** skjema og migreringer, strømmende IMDb-import, GraphQL-API (søk, fasetter, + cursor-paginering, anmeldelser, «min liste», slett egen anmeldelse), validering, feilmasking, + dybde- og kostnadsgrense, rate limiting, `/health`, TMDB- og Internet Archive-integrasjon. +- **Frontend:** alle sider og komponenter, Apollo-oppsett og cache-policyer, URL-styrt + søketilstand, videospiller, kategorinavigasjon, feilbanner, tema-bryter. +- **Tester:** API-tester mot ekte PostgreSQL, komponenttester, Playwright E2E med axe på desktop og + mobil, i lys og mørk modus og ved 320 px. +- **Drift og dokumentasjon:** deploy-skript, systemd-enhet, Apache-konfig, CI-oppsett og + dokumentene i `docs/`, inkludert README og denne fila. + +## Hva mennesker har gjort og bestemt + +- **Valg av domene og datasett:** film og serier fra IMDb sine åpne datasett, med plakater fra TMDB + og lovlige gratisfilmer fra Internet Archive. +- **Rammer og kvalitetskrav** i `CLAUDE.md`, og godkjenning eller avvisning av planer. +- **Drift av VM-en:** installasjon av PostgreSQL, import av det ekte datasettet (190 607 titler), + kjøring av deploy-skriptet og feilsøking på `it2810-17.idi.ntnu.no`. KI-en har ikke hatt tilgang + til VM-en eller NTNU-nettet. +- **Vurdering av medstudenttilbakemeldinger.** Nicolay fant at feilen medstudenten så («Kunne ikke + hente denne raden») skyldtes at testeren ikke var på NTNU-nettet, ikke en feil på VM-en. Det ga + oppgaven om ett forklarende banner i stedet for mock-data (#13). +- **Prioritering:** hvilke tilbakemeldinger som skulle tas (etikett, kategorinavigasjon, + feiltilstand) og i hvilken rekkefølge. +- **Prosesskrav til KI-en:** ingen KI-signaturer i commits og PR-er, og at historikk ikke skrives + om. + +Den enkeltes bidrag i gruppa dokumenteres i egen fil i Canvas, ikke her, jf. oppgaveteksten. + +## Hvordan vi kontrollerte KI-ens arbeid + +- **Automatiske tester er porten.** Ingen endring er merget uten grønn lint, typecheck, + komponenttester, API-tester mot PostgreSQL og E2E med axe. Tallene står i README og + `ki-logg.md`. +- **Uavhengig kodegjennomgang.** En egen kritiker-agent, med en kraftigere modell enn + utvikler-agentene, leste hver diff med eksplisitte sjekkpunkter: SQL-sikkerhet, ytelse, + tilgjengelighet, testkvalitet og kommentarkvalitet. Reviewene ligger på PR-ene. +- **Rotårsak, ikke omkjøring.** En ustabil test skal forklares, ikke kjøres på nytt til den blir + grønn. Testen i `watch.spec.ts` viste seg å avsløre en ekte feil (se under). +- **Målinger i stedet for påstander.** Ytelsespåstander er dokumentert med `EXPLAIN ANALYZE` i + [`ytelse.md`](ytelse.md), med åpen merknad om at tallene er fra et syntetisk datasett. + +## Feil KI-en gjorde, og hvordan de ble oppdaget + +Utvalg av feil fra KI-generert kode som ble fanget av tester eller review før de nådde brukerne: + +| Feil | Oppdaget av | Rettet i | +| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | -------- | +| Appen krasjet over http på VM-en fordi `crypto.randomUUID` bare finnes i sikre kontekster | Nicolay ved deploy | M5 | +| Avkrysningsboksene for filter «hoppet tilbake» i ca. 20 ms etter klikk (React-transition). Testen var ustabil i lang tid | Rotårsaksanalyse av ustabil E2E | #18 | +| `Requires=postgresql.service` i systemd ville latt backend stå stoppet etter en omstart av databasen | Kritiker-review | #19 | +| Database nede ga fortsatt én feilmelding per rad, fordi backenden svarte HTTP 200 med maskert feil | Kritiker-review | #19 | +| Et 636 byte stort GraphQL-dokument med nøstede fragmenter brukte ca. 60 s CPU (også i den eldre dybdegrensen) | Kritiker-review | #20 | +| Fullbreddetegn (`%`, `_`) ble til jokertegn etter aksentfolding, så søk kunne treffe hele tabellen | Kritiker-review | #20 | +| Korte søk matchet bare starten av tittelen («ma» fant ikke «The Matrix») | Kritiker-review | #20 | +| Hero-knappen viste alltid «Legg i min liste», også for titler som allerede var i lista | Kritiker-gjennomgang mot kriteriene | #16 | +| Hopp til en tom rad i kategorinavigasjonen mistet tastaturfokus | Kritiker-review | #18 | + +## Avvik i selve KI-prosessen + +Vi tar med disse fordi de viser hvor KI-arbeid må passes på: + +- **Commit-forfatter.** I sky-miljøet var git-identiteten forhåndsinnstilt til «Claude». Noen få + commits fra 6. oktober har derfor feil forfatter. Det ble oppdaget av leder-agenten, identiteten + ble rettet for alle senere commits, og omskriving av allerede pushet historikk ble stoppet av + miljøets sikkerhetsregler. Det er lagt fram for Nicolay som avgjør. +- **Delte porter mellom parallelle agenter.** E2E-testene bruker faste porter. Da flere agenter + kjørte testene samtidig, gjenbrukte Playwright en annen agents server og ga falske feil. Løst + med en felles lås for testkjøring. Feilene var ikke kodefeil, men viser at «grønt hos agenten» + må verifiseres på nytt etter merge. +- **Instrukser som ikke ble fulgt fullt ut.** En kritiker-agent byttet gren i hovedkatalogen under + review (rettet straks, ingen filer endret). Leder skjerpet instruksen for neste runde. + +## Begrensninger og ting vi ikke har kunnet verifisere + +- **Ytelse er målt på syntetiske data** (120 000 titler) i utviklingsmiljøet. VM-en har det ekte + datasettet, men KI-en har ikke nettverkstilgang dit. +- **GitLab CI er skrevet, men ikke kjørt** av KI-en, fordi repoet ligger på GitHub fram til + innlevering. +- **KI-ens egne vurderinger** av kontrast, opplesning og brukervennlighet er støttet av axe og + tester, men erstatter ikke testing med faktiske skjermleserbrukere. + +## Personvern, sikkerhet og opphav + +- Ingen hemmeligheter i git (`.env` er ignorert, `.env.example` ligger i repoet). KI-agentene har + ikke fått passord eller VPN-tilgang. +- Ingen personopplysninger er sendt til KI-en ut over navn på gruppemedlemmer i README. +- Data og media brukes i tråd med vilkårene: IMDb non-commercial datasets, TMDB med påkrevd + attribusjon i footeren, og bare Internet Archive-filmer merket som public domain eller Creative + Commons (lisens vises under spilleren). + +## Refleksjon + +- KI-en er rask til å lage mye kode som ser riktig ut. Verdien kom først når den ble kombinert med + **strenge rammer** (`CLAUDE.md`), **små oppgaver med tydelige ferdigkriterier** og **en uavhengig + gjennomgang** som leter etter feil. Flere av de alvorligste feilene over ville bestått alle + testene utvikler-agenten selv skrev. +- Tilbakemeldingene fra medstudenter var verdifulle fordi de kom fra ekte bruk. KI-en kunne ikke + selv ha oppdaget at testere utenfor NTNU-nettet fikk en uforståelig feilside. +- Prosess må være synlig for å kunne vurderes. Derfor er arbeidet fra oktober gjort gjennom issues, + pull requests og reviewkommentarer i stedet for direkte commits. From 8e2c1e787b3ba0ea84e169d3c96da1d400c1c97f Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:45:40 +0000 Subject: [PATCH 025/132] feat(frontend): delete own reviews with inline confirmation (#16) --- backend/test/server.test.ts | 10 +- .../src/components/DeleteReviewButton.tsx | 104 +++++++++++ frontend/src/components/ListToggleButton.tsx | 13 +- frontend/src/components/ReviewForm.tsx | 7 +- frontend/src/components/ReviewList.tsx | 17 +- frontend/src/graphql/operations.ts | 16 ++ frontend/src/graphql/types.ts | 10 + frontend/src/lib/apiError.ts | 17 ++ frontend/src/pages/MyListPage.tsx | 13 +- frontend/src/pages/TitlePage.delete.test.tsx | 173 ++++++++++++++++++ frontend/src/pages/TitlePage.tsx | 21 ++- frontend/src/styles/global.css | 15 +- frontend/src/test/utils.tsx | 3 + 13 files changed, 398 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/DeleteReviewButton.tsx create mode 100644 frontend/src/lib/apiError.ts create mode 100644 frontend/src/pages/TitlePage.delete.test.tsx diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts index f1c3bbf..db4d8d7 100644 --- a/backend/test/server.test.ts +++ b/backend/test/server.test.ts @@ -111,8 +111,14 @@ describe('servergrenser', () => { 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 } + // Frontenden sender `first` som variabel, som kostnadsberegningen regner som 50. + const FEATURED = `query Featured($first: Int) { search(first: $first) { pageInfo { hasNextPage endCursor } edges { cursor node { ${SUMMARY} ${HERO} inMyList } } } }`; + // Forsideradene (ROW_QUERY) ber ikke om totalCount. + const ROW = `query Row($first: Int) { search(first: $first) { pageInfo { hasNextPage endCursor } + edges { cursor node { ${SUMMARY} } } } }`; + const SEARCH = `query Search($first: Int) { search(first: $first) { totalCount pageInfo { hasNextPage endCursor } + edges { cursor node { ${SUMMARY} } } } }`; 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 } @@ -120,7 +126,7 @@ describe('servergrenser', () => { 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]) { + for (const q of [FEATURED, ROW, SEARCH, TITLE]) { const res = await run(q); expect(res.errors, q.slice(0, 40)).toBeUndefined(); } diff --git a/frontend/src/components/DeleteReviewButton.tsx b/frontend/src/components/DeleteReviewButton.tsx new file mode 100644 index 0000000..c23e1da --- /dev/null +++ b/frontend/src/components/DeleteReviewButton.tsx @@ -0,0 +1,104 @@ +import { useRef, useState } from 'react'; +import { useMutation } from '@apollo/client/react'; +import { DELETE_REVIEW_MUTATION } from '../graphql/operations'; +import { rateLimitMessage } from '../lib/apiError'; + +interface Props { + reviewId: string; + titleId: string; + /** Kalles etter vellykket sletting, mens raden fortsatt er i DOM-en. Forelderen eier fokus og kunngjøring. */ + onDeleted: () => void; +} + +/** + * To-trinns sletting inline («Slett» → «Bekreft sletting»/«Avbryt») i stedet for window.confirm, + * som ikke kan stiles, er stygg på mobil og blokkerer tråden. + */ +export function DeleteReviewButton({ reviewId, titleId, onDeleted }: Props) { + const [deleteReview, { loading }] = useMutation(DELETE_REVIEW_MUTATION); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(''); + const confirmRef = useRef(null); + const startRef = useRef(null); + + const ask = () => { + setError(''); + setConfirming(true); + // Knappen som hadde fokus byttes ut; uten dette havner fokus på body. + queueMicrotask(() => confirmRef.current?.focus()); + }; + const cancel = () => { + setConfirming(false); + queueMicrotask(() => startRef.current?.focus()); + }; + + const confirm = async () => { + setError(''); + try { + await deleteReview({ + variables: { id: reviewId }, + update(cache, { data }) { + if (!data) return; + const { deletedId, title } = data.deleteReview; + // Tittelens snitt og antall oppdateres av payloaden (normalisert på Title:); + // anmeldelsen fjernes fra connection-lista og evictes, så den heller ikke ligger igjen i cachen. + cache.modify({ + id: cache.identify({ __typename: 'Title', id: title.id }), + fields: { + reviews(existing, { readField }) { + const edges = (existing.edges as unknown[]).filter( + (edge) => readField('id', readField('node', edge as never)) !== deletedId, + ); + return { + ...existing, + edges, + totalCount: Math.max( + 0, + (existing.totalCount as number) - (existing.edges.length - edges.length), + ), + }; + }, + }, + }); + cache.evict({ id: cache.identify({ __typename: 'Review', id: deletedId }) }); + cache.gc(); + }, + }); + onDeleted(); + } catch (e) { + setConfirming(false); + setError(rateLimitMessage(e) ?? 'Kunne ikke slette anmeldelsen. Prøv igjen.'); + queueMicrotask(() => startRef.current?.focus()); + } + }; + + return ( +
+ {confirming ? ( + <> + + + + ) : ( + + )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/frontend/src/components/ListToggleButton.tsx b/frontend/src/components/ListToggleButton.tsx index 89dd226..f083a31 100644 --- a/frontend/src/components/ListToggleButton.tsx +++ b/frontend/src/components/ListToggleButton.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useMutation } from '@apollo/client/react'; import { TOGGLE_LIST_MUTATION } from '../graphql/operations'; +import { rateLimitMessage } from '../lib/apiError'; interface Props { titleId: string; @@ -17,17 +18,17 @@ interface Props { export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Props) { const [toggleList, { loading }] = useMutation(TOGGLE_LIST_MUTATION); const [message, setMessage] = useState(''); - const [failed, setFailed] = useState(false); + const [error, setError] = useState(''); const onClick = async () => { - setFailed(false); + setError(''); try { // Tittelen normaliseres på id, så knappen oppdateres av cachen uten ekstra kode. const { data } = await toggleList({ variables: { titleId } }); setMessage(data?.toggleList.inMyList ? 'Lagt til i min liste.' : 'Fjernet fra min liste.'); - } catch { + } catch (e) { setMessage(''); - setFailed(true); + setError(rateLimitMessage(e) ?? 'Kunne ikke oppdatere listen. Prøv igjen.'); } }; @@ -44,9 +45,9 @@ export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Pro

{message}

- {failed && ( + {error && (

- Kunne ikke oppdatere listen. Prøv igjen. + {error}

)} diff --git a/frontend/src/components/ReviewForm.tsx b/frontend/src/components/ReviewForm.tsx index a228fca..8bc6d19 100644 --- a/frontend/src/components/ReviewForm.tsx +++ b/frontend/src/components/ReviewForm.tsx @@ -1,6 +1,7 @@ import { useId, useRef, useState, type FormEvent } from 'react'; import { useMutation } from '@apollo/client/react'; import { ADD_REVIEW_MUTATION } from '../graphql/operations'; +import { rateLimitMessage } from '../lib/apiError'; import { MAX_AUTHOR, MAX_TEXT, @@ -72,8 +73,10 @@ export function ReviewForm({ titleId, onSubmitted }: Props) { }, }, }); - } catch { - setServerError('Kunne ikke sende anmeldelsen. Sjekk nettverket og prøv igjen.'); + } catch (error) { + setServerError( + rateLimitMessage(error) ?? 'Kunne ikke sende anmeldelsen. Sjekk nettverket og prøv igjen.', + ); return; } setDraft({ author: draft.author, rating: null, text: '' }); diff --git a/frontend/src/components/ReviewList.tsx b/frontend/src/components/ReviewList.tsx index 177d16a..ea08912 100644 --- a/frontend/src/components/ReviewList.tsx +++ b/frontend/src/components/ReviewList.tsx @@ -1,8 +1,16 @@ import { formatDate } from '../lib/format'; import type { Review } from '../graphql/types'; +import { DeleteReviewButton } from './DeleteReviewButton'; import { Stars } from './Stars'; -export function ReviewList({ reviews }: { reviews: Review[] }) { +export function ReviewList({ + reviews, + onDeleted, +}: { + reviews: Review[]; + /** Kalles etter at brukeren har slettet sin egen anmeldelse. */ + onDeleted?: () => void; +}) { if (reviews.length === 0) { return

Ingen anmeldelser ennå. Bli den første!

; } @@ -20,6 +28,13 @@ export function ReviewList({ reviews }: { reviews: Review[] }) { {r.text &&

{r.text}

} + {r.isMine && ( + onDeleted?.()} + /> + )} ))} diff --git a/frontend/src/graphql/operations.ts b/frontend/src/graphql/operations.ts index 78a662a..f4d50e5 100644 --- a/frontend/src/graphql/operations.ts +++ b/frontend/src/graphql/operations.ts @@ -2,6 +2,8 @@ import { gql, type TypedDocumentNode } from '@apollo/client'; import type { AddReviewData, AddReviewVars, + DeleteReviewData, + DeleteReviewVars, FacetsData, FeaturedData, FacetsVars, @@ -250,3 +252,17 @@ export const TOGGLE_LIST_MUTATION: TypedDocumentNode` i cachen oppdateres uten ny henting. +export const DELETE_REVIEW_MUTATION: TypedDocumentNode = gql` + mutation DeleteReview($id: ID!) { + deleteReview(id: $id) { + deletedId + title { + id + userRating + reviewCount + } + } + } +`; diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index aefb7aa..75aa7ab 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -178,6 +178,16 @@ export interface AddReviewVars { input: { titleId: string; author: string; rating: number; text: string }; } +export interface DeleteReviewData { + deleteReview: { + deletedId: string; + title: { __typename?: 'Title'; id: string; userRating: number | null; reviewCount: number }; + }; +} +export interface DeleteReviewVars { + id: string; +} + export interface ToggleListData { toggleList: { __typename?: 'Title'; id: string; inMyList: boolean }; } diff --git a/frontend/src/lib/apiError.ts b/frontend/src/lib/apiError.ts new file mode 100644 index 0000000..ca88aed --- /dev/null +++ b/frontend/src/lib/apiError.ts @@ -0,0 +1,17 @@ +import { CombinedGraphQLErrors } from '@apollo/client/errors'; + +/** + * Forståelig melding når serveren begrenser mutations (`RATE_LIMITED`), ellers `null`. + * Ventetiden kommer fra serveren, så brukeren vet når det er vits å prøve igjen. + */ +export function rateLimitMessage(error: unknown): string | null { + if (!CombinedGraphQLErrors.is(error)) return null; + const limited = error.errors.find((e) => e.extensions?.code === 'RATE_LIMITED'); + if (!limited) return null; + const seconds = Number(limited.extensions?.retryAfterSeconds); + if (!Number.isFinite(seconds) || seconds < 1) { + return 'For mange forsøk. Vent litt og prøv igjen.'; + } + const n = Math.ceil(seconds); + return `For mange forsøk. Vent ${n} ${n === 1 ? 'sekund' : 'sekunder'} og prøv igjen.`; +} diff --git a/frontend/src/pages/MyListPage.tsx b/frontend/src/pages/MyListPage.tsx index e75986b..6ce94d5 100644 --- a/frontend/src/pages/MyListPage.tsx +++ b/frontend/src/pages/MyListPage.tsx @@ -6,6 +6,7 @@ import { GRID_SIZES, PosterCard } from '../components/PosterCard'; import { PosterSkeleton } from '../components/PosterSkeleton'; import { MY_LIST_QUERY, TOGGLE_LIST_MUTATION } from '../graphql/operations'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; +import { rateLimitMessage } from '../lib/apiError'; import { formatNumber } from '../lib/format'; const PAGE_SIZE = 20; @@ -19,7 +20,7 @@ export default function MyListPage() { }); const [toggleList, { loading: removing }] = useMutation(TOGGLE_LIST_MUTATION); const [message, setMessage] = useState(''); - const [removeFailed, setRemoveFailed] = useState(false); + const [removeError, setRemoveError] = useState(''); const [moreFailed, setMoreFailed] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const heading = useRef(null); @@ -27,7 +28,7 @@ export default function MyListPage() { const list = data?.myList; const remove = async (id: string, name: string) => { - setRemoveFailed(false); + setRemoveError(''); try { await toggleList({ variables: { titleId: id }, @@ -55,8 +56,8 @@ export default function MyListPage() { setMessage(`Fjernet «${name}» fra listen.`); // Knappen som hadde fokus forsvinner; flytt fokus til overskriften så tastaturbrukere ikke mister plassen. heading.current?.focus(); - } catch { - setRemoveFailed(true); + } catch (e) { + setRemoveError(rateLimitMessage(e) ?? 'Kunne ikke fjerne tittelen. Prøv igjen.'); } }; @@ -68,9 +69,9 @@ export default function MyListPage() {

{message || (list ? `${formatNumber(list.totalCount)} titler` : '')}

- {removeFailed && ( + {removeError && (

- Kunne ikke fjerne tittelen. Prøv igjen. + {removeError}

)} diff --git a/frontend/src/pages/TitlePage.delete.test.tsx b/frontend/src/pages/TitlePage.delete.test.tsx new file mode 100644 index 0000000..68fd277 --- /dev/null +++ b/frontend/src/pages/TitlePage.delete.test.tsx @@ -0,0 +1,173 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GraphQLError } from 'graphql'; +import { Route, Routes } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import { + ADD_REVIEW_MUTATION, + DELETE_REVIEW_MUTATION, + TOGGLE_LIST_MUTATION, +} from '../graphql/operations'; +import { + buildMocks, + emptyLog, + makeDetails, + makeReview, + makeReviewConnection, + renderApp, + type Vars, +} from '../test/utils'; +import TitlePage from './TitlePage'; + +const mine = makeReview(1, { isMine: true, author: 'Meg' }); +const other = makeReview(2, { author: 'Andre' }); + +function setup( + before: Parameters[2] = [], + handlers: Parameters[0] = {}, +) { + const log = emptyLog(); + renderApp( + + } /> + , + { + route: '/title/tt0000001', + mocks: buildMocks( + { + TitleDetails: () => ({ + title: makeDetails({ + userRating: 3, + reviewCount: 2, + reviews: makeReviewConnection([mine, other]), + }), + }), + ...handlers, + }, + log, + before, + ), + }, + ); + return { log, user: userEvent.setup() }; +} + +const rateLimited = (query: unknown, seconds: number | undefined) => ({ + request: { query: query as never, variables: () => true }, + maxUsageCount: 1, + result: { + errors: [ + new GraphQLError('For mange forsøk.', { + extensions: { code: 'RATE_LIMITED', retryAfterSeconds: seconds }, + }), + ], + }, +}); + +describe('TitlePage: slett egen anmeldelse', () => { + it('viser «Slett» bare på egne anmeldelser', async () => { + setup(); + await screen.findByText('Andre'); + expect(screen.getAllByRole('button', { name: /^Slett/ })).toHaveLength(1); + const own = screen.getByText('Meg').closest('article')!; + expect(within(own).getByRole('button', { name: /^Slett/ })).toBeInTheDocument(); + }); + + it('krever bekreftelse, fjerner anmeldelsen, oppdaterer snitt og flytter fokus', async () => { + const { log, user } = setup([], { + DeleteReview: (vars: Vars) => ({ + deleteReview: { + __typename: 'DeleteReviewPayload', + deletedId: vars.id, + title: { __typename: 'Title', id: 'tt0000001', userRating: 4, reviewCount: 1 }, + }, + }), + }); + await user.click(await screen.findByRole('button', { name: /^Slett/ })); + // Første trykk sletter ikke; fokus flyttes til bekreftelsen. + expect(log.DeleteReview).toHaveLength(0); + const confirm = screen.getByRole('button', { name: 'Bekreft sletting' }); + expect(confirm).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Avbryt' })).toBeInTheDocument(); + + await user.click(confirm); + await screen.findByText('Anmeldelsen er slettet.'); + expect(log.DeleteReview).toEqual([{ id: 'r1' }]); + expect(screen.queryByText('Meg')).not.toBeInTheDocument(); + expect(screen.getByText('Andre')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Anmeldelser' })).toHaveFocus(); + // Snitt og antall kommer fra payloaden, uten ny henting av tittelen. + expect(screen.getByText(/1 anmeldelse\)/)).toBeInTheDocument(); + expect(screen.getByText('4,0')).toBeInTheDocument(); + expect(log.TitleDetails).toHaveLength(1); + }); + + it('«Avbryt» sletter ikke og gir fokus tilbake til «Slett»', async () => { + const { log, user } = setup(); + await user.click(await screen.findByRole('button', { name: /^Slett/ })); + await user.click(screen.getByRole('button', { name: 'Avbryt' })); + expect(log.DeleteReview).toHaveLength(0); + expect(screen.getByRole('button', { name: /^Slett/ })).toHaveFocus(); + expect(screen.getByText('Meg')).toBeInTheDocument(); + }); + + it('kan betjenes med tastatur', async () => { + const { log, user } = setup(); + const start = await screen.findByRole('button', { name: /^Slett/ }); + start.focus(); + await user.keyboard('{Enter}'); + // Tab fra bekreft-knappen går til «Avbryt» (to-trinns, ingen felle). + expect(log.DeleteReview).toHaveLength(0); + expect(screen.getByRole('button', { name: 'Bekreft sletting' })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'Avbryt' })).toHaveFocus(); + }); + + it('viser tydelig feil og beholder anmeldelsen når sletting feiler', async () => { + const { user } = setup(); + // Ingen DeleteReview-mock: mutasjonen feiler som ved nettverksbrudd. + await user.click(await screen.findByRole('button', { name: /^Slett/ })); + await user.click(screen.getByRole('button', { name: 'Bekreft sletting' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke slette anmeldelsen'); + expect(screen.getByText('Meg')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Slett/ })).toBeInTheDocument(); + }); + + it('viser ventetid ved RATE_LIMITED', async () => { + const { user } = setup([rateLimited(DELETE_REVIEW_MUTATION, 12)]); + await user.click(await screen.findByRole('button', { name: /^Slett/ })); + await user.click(screen.getByRole('button', { name: 'Bekreft sletting' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'For mange forsøk. Vent 12 sekunder og prøv igjen.', + ); + }); +}); + +describe('TitlePage: RATE_LIMITED på de andre mutasjonene', () => { + it('listeknappen viser ventetid', async () => { + const { user } = setup([rateLimited(TOGGLE_LIST_MUTATION, 1)]); + await user.click(await screen.findByRole('button', { name: 'Legg i min liste' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'For mange forsøk. Vent 1 sekund og prøv igjen.', + ); + }); + + it('anmeldelsesskjemaet viser ventetid og beholder det brukeren skrev', async () => { + const { user } = setup([rateLimited(ADD_REVIEW_MUTATION, 30)]); + await user.type(await screen.findByLabelText('Navn'), 'Kari'); + await user.click(screen.getByRole('radio', { name: '4 stjerner' })); + await user.click(screen.getByRole('button', { name: 'Send anmeldelse' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'For mange forsøk. Vent 30 sekunder og prøv igjen.', + ); + expect(screen.getByLabelText('Navn')).toHaveValue('Kari'); + }); + + it('faller tilbake til generell tekst uten retryAfterSeconds', async () => { + const { user } = setup([rateLimited(TOGGLE_LIST_MUTATION, undefined)]); + await user.click(await screen.findByRole('button', { name: 'Legg i min liste' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'For mange forsøk. Vent litt og prøv igjen.', + ); + }); +}); diff --git a/frontend/src/pages/TitlePage.tsx b/frontend/src/pages/TitlePage.tsx index 36f5045..2858534 100644 --- a/frontend/src/pages/TitlePage.tsx +++ b/frontend/src/pages/TitlePage.tsx @@ -1,4 +1,4 @@ -import { useState, type CSSProperties } from 'react'; +import { useRef, useState, type CSSProperties } from 'react'; import { Link, useLocation, useNavigate, useParams } from 'react-router'; import { useQuery } from '@apollo/client/react'; import { CombinedGraphQLErrors } from '@apollo/client'; @@ -49,6 +49,8 @@ export default function TitlePage() { }); const [loadingMore, setLoadingMore] = useState(false); const [moreFailed, setMoreFailed] = useState(false); + const [deleted, setDeleted] = useState(''); + const reviewsHeading = useRef(null); const title = data?.title; useDocumentTitle(title?.primaryTitle ?? 'Tittel'); @@ -208,7 +210,13 @@ export default function TitlePage() {
-

Anmeldelser

+

+ Anmeldelser +

+ {/* Regionen finnes hele tiden, ellers leses ikke meldingen opp. */} +

+ {deleted} +

{title.userRating != null && title.reviewCount > 0 ? ( <> @@ -221,7 +229,14 @@ export default function TitlePage() { )}

- e.node)} /> + e.node)} + onDeleted={() => { + setDeleted('Anmeldelsen er slettet.'); + // Slett-knappen forsvinner med raden; overskriften er et fast punkt å lande på. + reviewsHeading.current?.focus(); + }} + /> {moreFailed && (

diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f6b4f25..0856a9c 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -147,7 +147,8 @@ a { outline-offset: 2px; } main:focus, -main h1:focus { +main h1:focus, +#reviews-heading:focus { /* Programmatisk fokus etter rutebytte er ikke et interaktivt element; ring ville bare se ut som en feil. */ outline: none; } @@ -407,6 +408,18 @@ fieldset { border-color: rgb(255 255 255 / 0.35); color: #ffffff; } +.btn--danger { + background: transparent; + border-color: var(--danger); + color: var(--danger); +} +.review__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} .btn--link { background: none; border-color: transparent; diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx index 4d2584c..81d0a69 100644 --- a/frontend/src/test/utils.tsx +++ b/frontend/src/test/utils.tsx @@ -10,6 +10,7 @@ import { RouterProvider } from 'react-router/dom'; import { createCache } from '../apollo/cache'; import { ADD_REVIEW_MUTATION, + DELETE_REVIEW_MUTATION, FACETS_QUERY, FEATURED_QUERY, GENRES_QUERY, @@ -43,6 +44,7 @@ const OPERATIONS = { MyList: MY_LIST_QUERY, AddReview: ADD_REVIEW_MUTATION, ToggleList: TOGGLE_LIST_MUTATION, + DeleteReview: DELETE_REVIEW_MUTATION, } as const; export type OperationName = keyof typeof OPERATIONS; @@ -61,6 +63,7 @@ export function emptyLog(): CallLog { MyList: [], AddReview: [], ToggleList: [], + DeleteReview: [], }; } From 205f597fac588e62f1d057fb86c4edff7493f6c6 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:49:50 +0000 Subject: [PATCH 026/132] test(e2e): cover writing and deleting a review (#16) --- e2e/flow.spec.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/e2e/flow.spec.ts b/e2e/flow.spec.ts index 0dcc58c..8fcccc8 100644 --- a/e2e/flow.spec.ts +++ b/e2e/flow.spec.ts @@ -92,6 +92,45 @@ test('min liste: legg til fra detaljsiden og fjern igjen', async ({ page }) => { await expect(page.getByRole('link', { name: 'The Dark Knight' })).toHaveCount(0); }); +test('anmeldelser: skriv, slett med bekreftelse, og snitt og antall oppdateres', async ({ + page, +}) => { + await page.goto('./title/tt0111161'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('The Shawshank Redemption'); + const average = page.locator('.average'); + await expect(average).toContainText('Ingen brukeranmeldelser ennå.'); + + const text = `Slettes straks ${Date.now()}`; + await page.getByLabel('Navn').fill('Slette-tester'); + await page.getByRole('radio', { name: '5 stjerner' }).check(); + await page.getByRole('textbox', { name: /^Anmeldelse/ }).fill(text); + await page.getByRole('button', { name: 'Send anmeldelse' }).click(); + await expect(page.getByText(text)).toBeVisible(); + await expect(average).toContainText('5,0'); + await expect(average).toContainText('1 anmeldelse'); + + // Avbryt sletter ikke. + const review = page.getByRole('article').filter({ hasText: text }); + await review.getByRole('button', { name: /^Slett/ }).click(); + await review.getByRole('button', { name: 'Avbryt' }).click(); + await expect(page.getByText(text)).toBeVisible(); + + // Bekreftet sletting fjerner den, kunngjør det og flytter fokus til overskriften. + await review.getByRole('button', { name: /^Slett/ }).click(); + await page.getByRole('button', { name: 'Bekreft sletting' }).click(); + await expect(page.getByText(text)).toHaveCount(0); + await expect(page.getByRole('status').filter({ hasText: 'Anmeldelsen er slettet.' })).toHaveCount( + 1, + ); + await expect(page.getByRole('heading', { level: 2, name: 'Anmeldelser' })).toBeFocused(); + await expect(average).toContainText('Ingen brukeranmeldelser ennå.'); + + // Borte også etter omlasting (slettet i databasen, ikke bare i cachen). + await page.reload(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect(page.getByText(text)).toHaveCount(0); +}); + test('ingen treff og direkte lenke til ukjent side', async ({ page }) => { await page.goto('./?q=' + encodeURIComponent('zzqx%_\'"finnesikke')); await expect(page.locator('.count[role="status"]')).toHaveText(/^0 treff|Ingen treff/); From acab3a8341c0947e3b737cdf61c42a717ce4a7ac Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:55:48 +0000 Subject: [PATCH 027/132] ci: add build and e2e jobs with Postgres service (#17) --- .gitlab-ci.yml | 35 +++++++++++++++++++++++++++++++++++ playwright.config.ts | 7 ++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3d15688..d960316 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,7 @@ default: stages: - check - test + - e2e variables: HUSKY: '0' @@ -39,3 +40,37 @@ unit-tests: TEST_DATABASE_URL: postgres://postgres@postgres:5432/project2_test script: - npm test + +# Bygger begge pakkene slik at en ødelagt produksjonsbygging oppdages før deploy. +build: + stage: test + script: + - npm run build + artifacts: + paths: + - frontend/dist/ + expire_in: 1 week + +# E2E mot ekte backend og database. Playwright-bildet har nettleserne ferdig installert, så +# `playwright install` trengs ikke. Versjonen må følge @playwright/test i package-lock.json. +# Playwright starter selv falsk TMDB, falsk Internet Archive, backend og frontend (se +# playwright.config.ts), og global-setup.ts migrerer og fyller databasen med syntetiske data. +e2e: + stage: e2e + image: mcr.microsoft.com/playwright:v1.63.0-noble + services: + - name: postgres:16 + alias: postgres + variables: + POSTGRES_DB: project2_e2e + POSTGRES_USER: postgres + POSTGRES_HOST_AUTH_METHOD: trust + E2E_DATABASE_URL: postgres://postgres@postgres:5432/project2_e2e + script: + - npm run test:e2e + artifacts: + when: on_failure + paths: + - playwright-report/ + - test-results/ + expire_in: 1 week diff --git a/playwright.config.ts b/playwright.config.ts index 1ac7d67..802414a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import { e2ePolicy } from './e2e/csp-policy'; // E2E-testene kjører mot en egen database slik at anmeldelser og lister de lager ikke // blander seg med utviklingsdata. `e2e/global-setup.ts` migrerer og fyller den. @@ -16,7 +17,8 @@ export default defineConfig({ fullyParallel: false, workers: 1, retries: process.env.CI ? 1 : 0, - reporter: process.env.CI ? 'list' : [['list'], ['html', { open: 'never' }]], + // HTML-rapporten lastes opp som CI-artefakt når E2E feiler. + reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: 'http://localhost:4173/project2/', trace: 'retain-on-failure', @@ -65,6 +67,9 @@ export default defineConfig({ command: 'npm run build -w frontend && npm run preview -w frontend -- --port 4173 --strictPort', url: 'http://localhost:4173/project2/', + // Samme CSP som Apache sender i produksjon (lest fra deploy/apache-project2.conf), slik at + // alle E2E-testene fanger et nytt eksternt opphav som policyen ikke tillater. + env: { PREVIEW_CSP: e2ePolicy }, reuseExistingServer: !process.env.CI, timeout: 120_000, }, From 540f69f794733e5e2f39a61bbce3929777ae2a22 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:55:51 +0000 Subject: [PATCH 028/132] feat(deploy): add Content-Security-Policy and Permissions-Policy, verified in e2e (#17) --- deploy/apache-project2.conf | 8 ++++ docs/deploy.md | 20 ++++++++++ e2e/csp-policy.ts | 25 ++++++++++++ e2e/csp.spec.ts | 80 +++++++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 4 ++ 5 files changed, 137 insertions(+) create mode 100644 e2e/csp-policy.ts create mode 100644 e2e/csp.spec.ts diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf index f80460f..e35843e 100644 --- a/deploy/apache-project2.conf +++ b/deploy/apache-project2.conf @@ -35,6 +35,14 @@ Alias /project2 /var/www/html/project2 Header always set X-Content-Type-Options "nosniff" Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Cross-Origin-Opener-Policy "same-origin" + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interrupt-cohort=()" + # Content-Security-Policy: appen har bare to eksterne opphav, begge passive medier. Plakater + # kommer fra TMDB (image.tmdb.org). Filmer og undertekster kommer fra Internet Archive, som + # sender nedlastinger videre til lagringsverter som ia800000.us.archive.org (derfor wildcard). + # Alt annet, inkludert GraphQL-kallene, går til samme opphav. Vite-bygget har ingen inline + # skript eller stiler, så script-src og style-src trenger ikke 'unsafe-inline'. + # E2E-testen e2e/csp.spec.ts leser denne linjen, så ikke del den over flere linjer. + Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://image.tmdb.org; media-src 'self' https://archive.org https://*.archive.org; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" # Komprimer tekstressurser – store besparelser på JS/CSS/JSON for lite CPU. diff --git a/docs/deploy.md b/docs/deploy.md index a0970e6..eb83a37 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -73,3 +73,23 @@ cd /opt/project2 && git pull && npm ci && npm run build && npm run db:migrate sudo cp -r frontend/dist/* /var/www/html/project2/ sudo systemctl restart project2-backend ``` + +## Sikkerhetsheadere og Content-Security-Policy + +`deploy/apache-project2.conf` setter CSP, `Permissions-Policy`, `Referrer-Policy`, +`X-Content-Type-Options` og `X-Frame-Options` for `/project2`. CSP-en er satt så streng som appen tåler: +alt kommer fra samme opphav (`'self'`, ingen inline skript/stiler, GraphQL går via samme domene) bortsett +fra to passive medieopphav: plakater fra `https://image.tmdb.org` og film/undertekster fra +`https://archive.org` og `https://*.archive.org` (Archive videresender nedlastinger til verter som +`ia800000.us.archive.org`). Legger appen til et nytt eksternt opphav, må det inn i policyen, ellers blokkerer +nettleseren det. `e2e/csp.spec.ts` leser policyen rett fra Apache-konfigen (med mock-vertene byttet inn) og +sjekker at forside, detalj og spiller laster uten brudd, og feiler hvis `frontend/dist/index.html` har et inline-skript som ikke står som `'sha256-…'` i `script-src`. Hele E2E-suiten kjører dessuten under denne policyen (Vite preview får den som header). Husk `sudo a2enmod headers`. + +## CI (`.gitlab-ci.yml`) + +- `lint`, `typecheck`: kodekvalitet. `unit-tests`: `npm test` mot Postgres-service. +- `build`: `npm run build`, `frontend/dist/` lagres som artefakt. +- `e2e`: Playwright-bildet (`mcr.microsoft.com/playwright:v1.63.0-noble`, må følge versjonen i + `package-lock.json`) med `postgres:16` som service. `E2E_DATABASE_URL` peker mot service-verten; + Playwright starter mock-servere, backend og frontend selv. `playwright-report/` og `test-results/` + lagres som artefakt ved feil. diff --git a/e2e/csp-policy.ts b/e2e/csp-policy.ts new file mode 100644 index 0000000..0c975e6 --- /dev/null +++ b/e2e/csp-policy.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +// Policyen leses fra Apache-konfigen slik at E2E aldri kan skille lag fra det som deployes. +const conf = readFileSync( + path.resolve(import.meta.dirname, '..', 'deploy', 'apache-project2.conf'), + 'utf8', +); + +/** Policyen slik den står i Apache-konfigen (produksjon). */ +export const productionPolicy = /Header always set Content-Security-Policy "([^"]+)"/.exec( + conf, +)?.[1]; +if (!productionPolicy) { + throw new Error('Fant ikke Content-Security-Policy i deploy/apache-project2.conf'); +} + +/** + * Samme policy, men med de eksterne vertene byttet mot mock-serverne (TMDB på 3999, Internet + * Archive på 3998). Alt annet er uendret, så et nytt opphav appen begynner å bruke gir brudd. + */ +export const e2ePolicy = productionPolicy + .replace('https://image.tmdb.org', 'http://localhost:3999') + .replace('https://*.archive.org', 'http://localhost:3998') + .replace('https://archive.org', 'http://localhost:3998'); diff --git a/e2e/csp.spec.ts b/e2e/csp.spec.ts new file mode 100644 index 0000000..a1c4325 --- /dev/null +++ b/e2e/csp.spec.ts @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; +import { e2ePolicy, productionPolicy as real } from './csp-policy'; + +// Vite preview sender policyen som header (se playwright.config.ts), så hele E2E-suiten kjører +// under samme CSP som Apache setter i produksjon. Denne filen sjekker i tillegg eksplisitt at +// ingenting brytes. Policyen kan ikke legges på via page.route: Chrome behandler et fulfilled +// dokument som «unknown address space» og blokkerer da subressurser fra localhost. + +/** Samler CSP-brudd, både fra konsollmeldinger og fra securitypolicyviolation-hendelser. */ +async function collectViolations(page: Page) { + const violations: string[] = []; + page.on('console', (m) => { + if (/Content Security Policy/i.test(m.text())) violations.push(m.text()); + }); + await page.addInitScript(() => { + document.addEventListener('securitypolicyviolation', (e) => { + console.error(`Content Security Policy: ${e.violatedDirective} ${e.blockedURI}`); + }); + }); + return violations; +} + +test('policyen har de forventede, strenge grunndirektivene', () => { + expect(real).toContain("default-src 'self'"); + expect(real).toContain("frame-ancestors 'none'"); + expect(real).not.toContain('unsafe-inline'); + expect(real).not.toContain('unsafe-eval'); +}); + +test('forhåndsvisningsserveren sender policyen som header', async ({ request }) => { + const res = await request.get('./'); + expect(res.headers()['content-security-policy']).toBe(e2ePolicy); +}); + +test('forside, detalj og spiller laster uten CSP-brudd', async ({ page }) => { + const violations = await collectViolations(page); + await page.goto('./'); + // Plakatene kommer fra TMDB-mocken og skal faktisk ha lastet. + const img = page.locator('section.row article img').first(); + await expect(img).toBeVisible(); + await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBeGreaterThan(0); + + await page.goto('./?sort=relevans'); + await expect(page.locator('.count[role="status"]')).toHaveText(/\d[\d\s]* treff/); + + await page.goto('./title/tt0111161'); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + await page.goto('./watch/tt0211915'); + const video = page.locator('video'); + await expect(video).toBeVisible(); + await expect + .poll(() => video.evaluate((v: HTMLVideoElement) => v.readyState), { timeout: 15_000 }) + .toBeGreaterThan(0); + await expect(page.locator('track')).toHaveCount(1); + + expect(violations).toEqual([]); +}); + +test('alle inline-skript i det bygde index.html står som hash i script-src', () => { + // Inline-skript blokkeres av `script-src 'self'` med mindre innholdet er hash-godkjent. Hashen + // avhenger av byte-for-byte-innholdet, så endres skriptet uten at Apache-konfigen oppdateres + // brekker appen i produksjon. Testen feiler her i stedet, og med 0 inline-skript er den triviell. + const html = readFileSync( + path.resolve(import.meta.dirname, '..', 'frontend', 'dist', 'index.html'), + 'utf8', + ); + const scriptSrc = /(?:^|;\s*)script-src ([^;]+)/.exec(real)?.[1] ?? ''; + for (const m of html.matchAll(/]*\ssrc=)([^>]*)>([\s\S]*?)<\/script>/gi)) { + const code = m[2] ?? ''; + const hash = `'sha256-${createHash('sha256').update(code).digest('base64')}'`; + expect( + scriptSrc, + `Inline-skript mangler i script-src i deploy/apache-project2.conf. Legg til ${hash} (erstatt gammel hash). Skript: ${code.slice(0, 80)}`, + ).toContain(hash); + } +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3d54521..9be2cca 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ }, }, }, + preview: { + // Bare satt av E2E (playwright.config.ts) for å kjøre testene under produksjonens CSP. + headers: process.env.PREVIEW_CSP ? { 'Content-Security-Policy': process.env.PREVIEW_CSP } : {}, + }, test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], From 9d509dae2041e412059789bf978fe7703e0df7ad Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:56:24 +0000 Subject: [PATCH 029/132] fix(frontend): keep focus after delete cancel or failure and announce repeated deletes (#16) --- .../src/components/DeleteReviewButton.tsx | 32 ++++++++++---- frontend/src/components/Layout.test.tsx | 19 ++++++++- frontend/src/components/Layout.tsx | 21 ++++++++-- frontend/src/pages/TitlePage.delete.test.tsx | 42 ++++++++++++++++++- frontend/src/pages/TitlePage.tsx | 4 +- frontend/src/styles/global.css | 1 + 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/DeleteReviewButton.tsx b/frontend/src/components/DeleteReviewButton.tsx index c23e1da..9632aa4 100644 --- a/frontend/src/components/DeleteReviewButton.tsx +++ b/frontend/src/components/DeleteReviewButton.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation } from '@apollo/client/react'; import { DELETE_REVIEW_MUTATION } from '../graphql/operations'; import { rateLimitMessage } from '../lib/apiError'; @@ -18,21 +18,33 @@ export function DeleteReviewButton({ reviewId, titleId, onDeleted }: Props) { const [deleteReview, { loading }] = useMutation(DELETE_REVIEW_MUTATION); const [confirming, setConfirming] = useState(false); const [error, setError] = useState(''); + // Hvor fokus skal havne etter neste render. Knappene byttes ut, så fokus kan først flyttes når + // React har committet den nye knappen (et microtask rekker det ikke). + const focusNext = useRef<'start' | 'confirm' | null>(null); const confirmRef = useRef(null); const startRef = useRef(null); + // Uten avhengighetsliste: hver endring som bytter knapp gir en render, og da skal fokus flyttes. + useEffect(() => { + if (!focusNext.current) return; + (focusNext.current === 'confirm' ? confirmRef : startRef).current?.focus(); + focusNext.current = null; + }); + const ask = () => { setError(''); setConfirming(true); - // Knappen som hadde fokus byttes ut; uten dette havner fokus på body. - queueMicrotask(() => confirmRef.current?.focus()); + focusNext.current = 'confirm'; }; const cancel = () => { + if (loading) return; setConfirming(false); - queueMicrotask(() => startRef.current?.focus()); + focusNext.current = 'start'; }; const confirm = async () => { + // aria-disabled i stedet for disabled: en disabled knapp mister fokus i Chrome mens sletting pågår. + if (loading) return; setError(''); try { await deleteReview({ @@ -61,14 +73,13 @@ export function DeleteReviewButton({ reviewId, titleId, onDeleted }: Props) { }, }); cache.evict({ id: cache.identify({ __typename: 'Review', id: deletedId }) }); - cache.gc(); }, }); onDeleted(); } catch (e) { setConfirming(false); setError(rateLimitMessage(e) ?? 'Kunne ikke slette anmeldelsen. Prøv igjen.'); - queueMicrotask(() => startRef.current?.focus()); + focusNext.current = 'start'; } }; @@ -80,12 +91,17 @@ export function DeleteReviewButton({ reviewId, titleId, onDeleted }: Props) { ref={confirmRef} type="button" className="btn btn--danger" - disabled={loading} + aria-disabled={loading || undefined} onClick={() => void confirm()} > {loading ? 'Sletter …' : 'Bekreft sletting'} - diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx index d0c1809..aa91429 100644 --- a/frontend/src/components/Layout.test.tsx +++ b/frontend/src/components/Layout.test.tsx @@ -1,5 +1,5 @@ import { MockedProvider } from '@apollo/client/testing/react'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect, useState } from 'react'; import { createMemoryRouter, RouterProvider } from 'react-router'; @@ -148,6 +148,23 @@ describe('Layout', () => { await waitFor(() => expect(h1).toHaveFocus()); }); + it('stjeler ikke fokus etter klikk eller tastetrykk, selv om fokus har falt til body', async () => { + for (const interact of [ + () => fireEvent.pointerDown(document.body), + () => fireEvent.keyDown(document.body, { key: 'a' }), + ]) { + const router = setup('/'); + await act(() => router.navigate('/sen')); + interact(); + // Fokus faller til body (som når en knapp blir disabled). + (document.activeElement as HTMLElement).blur(); + const h1 = await screen.findByRole('heading', { level: 1, name: 'Ferdig lastet' }); + expect(h1).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + cleanup(); + } + }); + it('stjeler ikke fokus hvis brukeren har flyttet det før h1 kommer', async () => { const router = setup('/'); await act(() => router.navigate('/sen')); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0d88f2e..a3d1a16 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -38,13 +38,28 @@ export function Layout() { const active = document.activeElement; // Fokus er «mistet» (elementet ble fjernet) eller står der vi la det; ellers har brukeren flyttet det. if (active === document.body || active === el || active === focused) focusTarget(); - else observer.disconnect(); + else stop(); }); + // Så snart brukeren selv gjør noe, er fokus hennes. Uten dette ville fokus som faller til body + // (f.eks. når en knapp blir disabled) sett ut som «mistet», og vi ville hoppet tilbake til h1. + const onFocusIn = (e: FocusEvent) => { + const t = e.target as HTMLElement; + const ours = t === el || (t.tagName === 'H1' && el.contains(t)); + if (!ours) stop(); + }; + const events = ['pointerdown', 'keydown'] as const; + const stop = () => { + observer.disconnect(); + document.removeEventListener('focusin', onFocusIn); + for (const name of events) document.removeEventListener(name, stop, true); + }; observer.observe(el, { childList: true, subtree: true }); + document.addEventListener('focusin', onFocusIn); + for (const name of events) document.addEventListener(name, stop, true); // Gi opp etter en stund så innhold som dukker opp lenge etter ikke stjeler fokus. - const giveUp = window.setTimeout(() => observer.disconnect(), 3000); + const giveUp = window.setTimeout(stop, 3000); return () => { - observer.disconnect(); + stop(); window.clearTimeout(giveUp); }; }, [pathname]); diff --git a/frontend/src/pages/TitlePage.delete.test.tsx b/frontend/src/pages/TitlePage.delete.test.tsx index 68fd277..48f8998 100644 --- a/frontend/src/pages/TitlePage.delete.test.tsx +++ b/frontend/src/pages/TitlePage.delete.test.tsx @@ -1,4 +1,4 @@ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GraphQLError } from 'graphql'; import { Route, Routes } from 'react-router'; @@ -102,6 +102,43 @@ describe('TitlePage: slett egen anmeldelse', () => { expect(log.TitleDetails).toHaveLength(1); }); + it('kunngjør også den andre slettingen (regionen tømmes mellom)', async () => { + const second = makeReview(3, { isMine: true, author: 'Meg igjen' }); + const { user } = setup([], { + TitleDetails: () => ({ + title: makeDetails({ + userRating: 3, + reviewCount: 2, + reviews: makeReviewConnection([mine, second]), + }), + }), + DeleteReview: (vars: Vars) => ({ + deleteReview: { + __typename: 'DeleteReviewPayload', + deletedId: vars.id, + title: { __typename: 'Title', id: 'tt0000001', userRating: 3, reviewCount: 1 }, + }, + }), + }); + await screen.findByText('Meg igjen'); + // Statusregionen rett etter overskriften «Anmeldelser». + const status = screen.getByRole('heading', { name: 'Anmeldelser' }).nextElementSibling!; + const seen: string[] = []; + const observer = new MutationObserver(() => seen.push(status.textContent ?? '')); + observer.observe(status, { childList: true, characterData: true, subtree: true }); + + for (const name of ['Meg', 'Meg igjen']) { + const article = screen.getByText(name).closest('article')!; + await user.click(within(article).getByRole('button', { name: /^Slett/ })); + await user.click(within(article).getByRole('button', { name: 'Bekreft sletting' })); + await waitFor(() => expect(screen.queryByText(name)).not.toBeInTheDocument()); + await waitFor(() => expect(status).toHaveTextContent('Anmeldelsen er slettet.')); + } + observer.disconnect(); + // Tekst, tom, tekst: uten tømmingen ville den andre meldingen vært en uendret verdi. + expect(seen.join('|')).toBe('Anmeldelsen er slettet.||Anmeldelsen er slettet.'); + }); + it('«Avbryt» sletter ikke og gir fokus tilbake til «Slett»', async () => { const { log, user } = setup(); await user.click(await screen.findByRole('button', { name: /^Slett/ })); @@ -130,7 +167,8 @@ describe('TitlePage: slett egen anmeldelse', () => { await user.click(screen.getByRole('button', { name: 'Bekreft sletting' })); expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke slette anmeldelsen'); expect(screen.getByText('Meg')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^Slett/ })).toBeInTheDocument(); + // Fokus må ikke falle til body når knappene byttes ut. + await waitFor(() => expect(screen.getByRole('button', { name: /^Slett/ })).toHaveFocus()); }); it('viser ventetid ved RATE_LIMITED', async () => { diff --git a/frontend/src/pages/TitlePage.tsx b/frontend/src/pages/TitlePage.tsx index 2858534..dbf6c9c 100644 --- a/frontend/src/pages/TitlePage.tsx +++ b/frontend/src/pages/TitlePage.tsx @@ -232,7 +232,9 @@ export default function TitlePage() { e.node)} onDeleted={() => { - setDeleted('Anmeldelsen er slettet.'); + // Regionen tømmes først: identisk tekst som ved forrige sletting leses ellers ikke opp igjen. + setDeleted(''); + window.setTimeout(() => setDeleted('Anmeldelsen er slettet.'), 100); // Slett-knappen forsvinner med raden; overskriften er et fast punkt å lande på. reviewsHeading.current?.focus(); }} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 0856a9c..3505a3c 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -389,6 +389,7 @@ fieldset { .btn:hover:not(:disabled) { border-color: var(--accent-text); } +.btn[aria-disabled='true'], .btn:disabled { cursor: progress; opacity: 0.7; From b7fa158b0bb169ff4fe0c293a6c0b70719d3528b Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:56:40 +0000 Subject: [PATCH 030/132] docs: rewrite README to discuss all major choices (draft) (#17) --- README.md | 346 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 304 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e7e6cc2..469914d 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,340 @@ # Filmsøk – IT2810 prosjekt 2, gruppe 17 -Søk, filtrer og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB. +Søk, filtrer, sorter og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB og +lovlige gratisfilmer fra Internet Archive som kan spilles direkte i appen. -**🔗 Kjørende versjon:** (NTNU-nett eller VPN) +**🔗 Kjørende versjon:** . Krever NTNU-nett eller VPN. +Utenfor NTNU-nettet når ikke nettleseren API-et, og appen viser et banner som forklarer dette. + +## Innhold + +1. [Arbeidsfordeling](#arbeidsfordeling) +2. [Funksjonalitet](#funksjonalitet) +3. [Arkitektur](#arkitektur) +4. [Valg og begrunnelser](#valg-og-begrunnelser) +5. [Tilgjengelighet](#tilgjengelighet) +6. [Bærekraft](#bærekraft) +7. [Sikkerhet og robusthet](#sikkerhet-og-robusthet) +8. [Testing](#testing) +9. [Kjøre lokalt](#kjøre-lokalt) +10. [Deploy på VM](#deploy-på-vm) +11. [Prosess og bruk av KI](#prosess-og-bruk-av-ki) +12. [Kjente begrensninger](#kjente-begrensninger) +13. [Videre dokumentasjon](#videre-dokumentasjon) ## Arbeidsfordeling | Navn | Rolle | | ------- | -------------------------------------------------------------------- | | Nicolay | Prosjektleder. Arkitektur, GraphQL-API og React, frontend og backend | -| Strula | Backendansvarlig: database, import og resolvere | +| Sturla | Backendansvarlig: database, import og resolvere | | Brage | Frontendansvarlig: komponenter, design og tilgjengelighet | | Daniel | Testansvarlig: enhets-, komponent- og E2E-tester | +Den enkeltes bidrag er beskrevet i egen fil i Canvas. + ## Funksjonalitet -- Søk på tittel med 300 ms debounce, uten skille på store og små bokstaver -- Filter på sjanger, tiår, type og rating, med antall treff per valg -- Sortering på relevans, rating, år og tittel, alltid på serveren -- Uendelig scroll med «Last flere»-knapp -- Detaljside, anmeldelser (1–5 stjerner) og «Min liste» -- Søk og filtre i URL-en, mørk og lys modus, responsivt ned til 320 px +| Krav i oppgaven | Slik er det løst | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Søk | Søkefelt med 300 ms debounce. Skiller ikke store og små bokstaver eller aksenter («amelie» finner «Amélie»). Ingen request for tomt eller uendret søk. Korte søk (1–2 tegn) matcher starten av ord | +| Store resultatsett | Uendelig scroll med «Last flere»-knapp som alternativ. Cursor-paginering (keyset) i SQL, 20 per side | +| Detaljer | Egen side per tittel med handling, sjangre, rating, anmeldelser og eventuell gratisfilm | +| Sortering og filtrering | Sjanger, tiår, type, minste rating og «Kun filmer som kan strømmes gratis». Antall treff per valg. Sortering på relevans, rating, år og tittel. Alt skjer i SQL på hele datasettet | +| Brukergenererte data | Anmeldelser (1–5 stjerner og tekst) og «Min liste», lagret i PostgreSQL. Egne anmeldelser kan slettes | +| Tilgjengelighet | WCAG 2.1 AA. Se [Tilgjengelighet](#tilgjengelighet) | +| Bærekraft | Se [Bærekraft](#bærekraft) | +| Design | Kinoinspirert, mørkt grensesnitt med lys variant. «Bla-modus» med rader og kategorinavigasjon når søket er tomt, «søkemodus» med filtre og liste når man søker | -## Teknologi +I tillegg: -**Frontend:** React, TypeScript, Vite, Apollo Client · **Backend:** Node.js, GraphQL (Yoga) · -**Database:** PostgreSQL med `pg_trgm` · **Test:** Vitest, Testing Library, Playwright, axe +- **Søketilstanden ligger i URL-en.** Søk, filtre og sortering kan deles og bokmerkes, og + tilbake-knappen gjenoppretter både resultat og scroll-posisjon. +- **Gratisfilmer kan spilles i appen.** Bare filmer som Internet Archive merker som public domain + eller Creative Commons. Egen videospiller med tastaturstyring og undertekster når de finnes. +- **Tema:** følger systemet, og kan overstyres med en bryter i headeren. +- **Tydelige feiltilstander:** tomt søk, ingen treff, nettverksfeil med «Prøv igjen», og ett + forklarende banner når API-et ikke kan nås. -All søk, filtrering og sortering skjer i SQL med cursor-paginering. API-et er beskrevet i -[`docs/api.md`](docs/api.md). +## Arkitektur -## Pipeline +``` +Nettleser (React + Apollo Client) + │ GET /project2/… statiske filer (Vite-bygg), Apache med gzip og cache-headere + │ POST /project2/graphql ──▶ Apache (reverse proxy) ──▶ Node + GraphQL Yoga :3001 (127.0.0.1) + │ │ + │ ├─▶ PostgreSQL (pg_trgm, unaccent) + │ └─▶ TMDB API (plakater, bufret i DB) + └─ video ──────────────────────────────────────────────────────▶ archive.org (direkte) +``` ``` -feature-gren ─▶ pre-commit ─▶ pull request ─▶ CI ─▶ main ─▶ deploy til VM +backend/ + migrations/ SQL-migreringer (skjema, indekser, genererte søkekolonner) + scripts/ migrering, IMDb-import, Internet Archive-import, syntetisk datasett, falske tjenester for test + src/ GraphQL-skjema, resolvere, søk (search.ts), validering, grenser, feilhåndtering + test/ API-tester mot ekte PostgreSQL +frontend/src/ + apollo/ klient, cache-policyer, nedetidstilstand (reactive var) + components/ gjenbrukbare komponenter med tester ved siden av + pages/ sider (lazy-lastet unntatt forsiden) + hooks/, lib/ søketilstand ↔ URL, debounce, uendelig scroll, formatering +e2e/ Playwright-tester med axe +deploy/ systemd-enhet, Apache-konfig, oppsett- og oppdateringsskript for VM-en +docs/ beslutninger, API-kontrakt, oppsett, deploy, ytelse, KI-logg og KI-deklarasjon ``` -1. **Pre-commit (Husky + lint-staged):** ESLint og Prettier på endrede filer. -2. **CI (`.gitlab-ci.yml`):** lint, typecheck og enhetstester mot PostgreSQL. -3. **Review og merge** til `main`. -4. **Deploy med én kommando:** - ```bash - ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh - ``` - Kommandoen henter siste kode, bygger, migrerer databasen, importerer data, henter plakater og - starter backend (systemd) og Apache på nytt. Den stopper med feilmelding hvis API-et ikke svarer. +## Valg og begrunnelser + +Alle valg er beskrevet med begrunnelse i [`docs/beslutninger.md`](docs/beslutninger.md). Her er de +viktigste. + +### Datasett + +[IMDb non-commercial datasets](https://developer.imdb.com/non-commercial-datasets/) er åpne, store +og realistiske. Vi tar med filmer og serier med minst 100 stemmer, som gir 190 607 titler på VM-en. +IMDb har ingen bilder, så plakater og handling hentes fra TMDB første gang en tittel vises, og +lagres i databasen. Uten TMDB-nøkkel vises plassholdere. + +### Database: PostgreSQL + +Søk med delstrenger, fasettelling og stabil paginering over hundretusener av rader krever en +database som kan indeksere tekst. PostgreSQL gir dette uten ekstra tjenester: + +- **`pg_trgm` med GIN-indekser** gjør delstrengsøk («dark» i «The Dark Knight») indeksert. + Det tar 3 ms i stedet for 77 ms på 120 000 titler. +- **`unaccent`** i en IMMUTABLE wrapper-funksjon, lagret i genererte kolonner. Dermed blir + aksentfolding gjort én gang ved skriving, ikke for hver rad ved hvert søk. +- **`tsvector` med prefikssøk** for søk på 1–2 tegn, der trigrammer ikke virker. «a» gikk fra + ca. 300 ms til 0,2 ms. +- **B-tre-indekser per sorteringsnøkkel**, slik at sortert paginering kan lese i indeksrekkefølge. + +Målinger før og etter står i [`docs/ytelse.md`](docs/ytelse.md). + +### Paginering: cursor (keyset), ikke `OFFSET` + +`OFFSET` blir tregere jo lenger ned man scroller, og gir hull eller duplikater hvis data endres. +Vi bruker radsammenligning på sorteringsnøklene med `id` som siste nøkkel, for eksempel +`(rating, votes, id) < ($1, $2, $3)`. Det er like raskt på side 1 og side 1000. Cursoren inneholder +en signatur for sorteringen og avvises hvis den brukes med en annen sortering. + +### Filtrering og fasetter + +Hver fasett telles med de _andre_ filtrene aktive. Antallet ved «Drama» viser altså hvor mange +treff du får hvis du også krysser av for Drama. Valgte verdier vises også når de har 0 treff, så de +ikke forsvinner under brukeren. `totalCount` regnes bare ut når klienten ber om det, og forsiderader +ber ikke om det. + +### API: GraphQL med GraphQL Yoga + +Klienten henter nøyaktig de feltene hver visning trenger. Listene henter for eksempel bare +`stream { url }`, mens detaljsiden henter hele strømmeinformasjonen. Kontrakten står i +[`docs/api.md`](docs/api.md). Viktige egenskaper: + +- **Ingen N+1:** en batch-loader per request henter `inMyList`, rating og antall anmeldelser for en + hel side i én spørring per felt. +- **Grenser:** + - `first` er maks 50. + - Spørringsdybden er maks 6. + - En kostnadsgrense teller felt og vekter lister med `first`. + - Rate limiting på mutations. + - `statement_timeout` er 15 s. +- **Feil:** egne feilkoder (`BAD_USER_INPUT`, `NOT_FOUND`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`). + Uventede feil maskeres, og detaljene logges bare på serveren. Introspeksjon og GraphiQL er av i + produksjon. + +### State: Apollo Client og URL-en + +- **URL-en er eneste kilde til søketilstand** (`useSearchState`). Da fungerer deling, bokmerker og + tilbake-knappen uten egen synkronisering. +- **Apollo-cachen** bruker `relayStylePagination` med sortering og filtre som nøkkel. Sider for + samme søk slås sammen, og et tilbake-klikk viser innlastede sider uten ny request. +- **Reactive var** brukes for global klienttilstand som ikke hører hjemme i URL-en, for eksempel + om API-et kan nås. +- **Anonym bruker-ID** (UUID i `localStorage`) sendes i en header. Vi har valgt bort innlogging for å + holde prosjektet innenfor rammen, se [Kjente begrensninger](#kjente-begrensninger). + +### Komponenter og biblioteker + +Vi har valgt få, veletablerte avhengigheter, og bevisst valgt bort tunge UI-biblioteker: + +| Bibliotek | Hvorfor | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| React 19, React Router, Vite | Krav i oppgaven. Router gir lazy-lastede ruter og innebygd scroll-gjenoppretting | +| Apollo Client | Normalisert cache, paginering og reactive vars. Erstatter egen state-håndtering | +| GraphQL Yoga, `pg` | Lett GraphQL-server med gode utvidelsespunkter for validering og feil. `pg` gir parametriserte spørringer direkte | +| Vitest, Testing Library, Playwright, axe | Komponenttester som tester oppførsel, E2E i ekte nettleser og automatisk WCAG-sjekk | +| ESLint (med `jsx-a11y`), Prettier, Husky | Lik kodestil og tilgjengelighetslinting før hver commit | + +**Ikke brukt, med vilje:** + +- **Komponentbibliotek (MUI o.l.):** kunne gitt raskere oppstart, men ville gitt mer JavaScript og + mindre kontroll over fokus og kontrast. +- **Spillerbibliotek:** videospilleren er bygd på `

- {/* Egen status-region utenfor alerten: tilbakemelding på mislykket forsøk uten å gjenta hele varselet. */}

- {failed ? 'Fortsatt ingen kontakt med serveren.' : ''} + {failed + ? kind === 'service' + ? 'Tjenesten er fortsatt utilgjengelig.' + : 'Fortsatt ingen kontakt med serveren.' + : ''}

diff --git a/frontend/src/components/ListToggleButton.test.tsx b/frontend/src/components/ListToggleButton.test.tsx new file mode 100644 index 0000000..1bf461e --- /dev/null +++ b/frontend/src/components/ListToggleButton.test.tsx @@ -0,0 +1,87 @@ +import { useApolloClient } from '@apollo/client/react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { MY_LIST_QUERY } from '../graphql/operations'; +import { buildMocks, emptyLog, makeConnection, makeTitle, renderApp } from '../test/utils'; +import { ListToggleButton } from './ListToggleButton'; + +/** Gjør klienten tilgjengelig for testen, slik at vi kan se hva mutasjonen gjør med cachen. */ +function ClientProbe({ onClient }: { onClient: (c: ReturnType) => void }) { + onClient(useApolloClient()); + return null; +} + +function setup(inMyList = false, delay = 0) { + const log = emptyLog(); + const mocks = buildMocks( + { + ToggleList: (vars) => ({ + toggleList: { __typename: 'Title', id: vars.titleId, inMyList: !inMyList }, + }), + }, + log, + ); + if (delay) for (const m of mocks) m.delay = delay; + let client!: ReturnType; + renderApp( + <> + (client = c)} /> + + , + { mocks }, + ); + return { log, client, user: userEvent.setup() }; +} + +describe('ListToggleButton', () => { + it('kunngjør utfallet i statusregionen', async () => { + const { user } = setup(false); + await user.click(screen.getByRole('button', { name: 'Legg i min liste' })); + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent('Lagt til i min liste.'), + ); + }); + + it('beholder fokus og avviser nytt trykk mens mutasjonen pågår (aria-disabled, ikke disabled)', async () => { + const { user, log } = setup(false, 100); + const button = screen.getByRole('button', { name: 'Legg i min liste' }); + await user.click(button); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).not.toBeDisabled(); + expect(button).toHaveFocus(); + await user.click(button); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Lagt til')); + expect(log.ToggleList).toHaveLength(1); + expect(button).not.toHaveAttribute('aria-disabled'); + }); + + it('kaster «Min liste» fra cachen, så den ikke viser gammelt innhold etter endringen', async () => { + const { user, client } = setup(false); + client.writeQuery({ + query: MY_LIST_QUERY, + variables: { first: 20 }, + data: { myList: makeConnection([makeTitle(9)]) }, + }); + const listKeys = () => { + const root = (client.cache.extract() as Record).ROOT_QUERY; + return Object.keys(root ?? {}).filter((k) => k.startsWith('myList')); + }; + expect(listKeys()).not.toEqual([]); + + await user.click(screen.getByRole('button', { name: 'Legg i min liste' })); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Lagt til')); + expect(listKeys()).toEqual([]); + }); + + it('viser feil når mutasjonen mislykkes, og knappen er brukbar igjen', async () => { + // Ingen ToggleList-mock: mutasjonen feiler. + renderApp(, { mocks: [] }); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Legg i min liste' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke oppdatere listen'); + expect(screen.getByRole('button', { name: 'Legg i min liste' })).not.toHaveAttribute( + 'aria-disabled', + ); + }); +}); diff --git a/frontend/src/components/ListToggleButton.tsx b/frontend/src/components/ListToggleButton.tsx index fc2f73f..70c7045 100644 --- a/frontend/src/components/ListToggleButton.tsx +++ b/frontend/src/components/ListToggleButton.tsx @@ -21,10 +21,19 @@ export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Pro const [error, setError] = useState(''); const onClick = async () => { + if (loading) return; setError(''); try { // Tittelen normaliseres på id, så knappen oppdateres av cachen uten ekstra kode. - const { data } = await toggleList({ variables: { titleId } }); + const { data } = await toggleList({ + variables: { titleId }, + // «Min liste» er en egen liste i cachen som ikke vet om denne endringen. Uten å kaste den ville en + // tilbakenavigering vist gammelt innhold til nettverkssvaret kom (eller for alltid ved nedetid). + update(cache) { + cache.evict({ fieldName: 'myList' }); + cache.gc(); + }, + }); setMessage(data?.toggleList.inMyList ? 'Lagt til i min liste.' : 'Fjernet fra min liste.'); } catch (e) { setMessage(''); @@ -37,7 +46,8 @@ export function ListToggleButton({ titleId, inMyList, variant = 'primary' }: Pro diff --git a/frontend/src/hooks/useMoreButtonFocus.ts b/frontend/src/hooks/useMoreButtonFocus.ts new file mode 100644 index 0000000..90b4b60 --- /dev/null +++ b/frontend/src/hooks/useMoreButtonFocus.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, type FocusEvent, type RefObject } from 'react'; + +/** + * «Last flere»/«Vis flere» fjernes når siste side er hentet. Hadde knappen fokus, faller det til + * og tastaturbrukeren mister plassen. Da flyttes fokus til `target` (et statisk punkt nær listen). + * + * Vi husker fokus via onFocus/onBlur i stedet for å se på document.activeElement etter at knappen er borte: + * da er det for sent. Blur med relatedTarget betyr at brukeren selv flyttet fokus videre; blur uten + * (nettleseren fjerner et fokusert element) skal ikke avvæpne oss. + */ +export function useMoreButtonFocus(hasNext: boolean, target: RefObject) { + const focused = useRef(false); + + useEffect(() => { + if (hasNext || !focused.current) return; + focused.current = false; + if (document.activeElement === document.body) target.current?.focus({ preventScroll: true }); + }, [hasNext, target]); + + return { + onFocus: () => { + focused.current = true; + }, + onBlur: (e: FocusEvent) => { + if (e.relatedTarget) focused.current = false; + }, + }; +} diff --git a/frontend/src/pages/MyListPage.test.tsx b/frontend/src/pages/MyListPage.test.tsx index a3613a1..75320af 100644 --- a/frontend/src/pages/MyListPage.test.tsx +++ b/frontend/src/pages/MyListPage.test.tsx @@ -97,6 +97,56 @@ describe('MyListPage', () => { }); }); +describe('MyListPage: fokus', () => { + it('«Last flere» som fjernes etter siste side flytter fokus til overskriften', async () => { + const user = userEvent.setup(); + const log = emptyLog(); + renderApp(, { + mocks: buildMocks( + { + MyList: (vars: Vars) => ({ + myList: vars.after + ? makeConnection([makeTitle(2)], 2, false) + : makeConnection([makeTitle(1)], 2, true), + }), + }, + log, + ), + }); + await screen.findByRole('link', { name: 'Tittel 1' }); + await user.click(screen.getByRole('button', { name: 'Last flere' })); + await screen.findByRole('link', { name: 'Tittel 2' }); + expect(screen.queryByRole('button', { name: 'Last flere' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 1 })).toHaveFocus(); + }); + + it('«Fjern» beholder fokus mens den jobber (aria-disabled) og hindrer dobbel forespørsel', async () => { + const user = userEvent.setup(); + const log = emptyLog(); + const mocks = buildMocks( + { + MyList: () => ({ myList: makeConnection([makeTitle(1), makeTitle(2)]) }), + ToggleList: (vars) => ({ + toggleList: { __typename: 'Title', id: vars.titleId, inMyList: false }, + }), + }, + log, + ); + for (const m of mocks) m.delay = 100; + renderApp(, { mocks }); + await screen.findByRole('link', { name: 'Tittel 1' }); + const button = screen.getByRole('button', { name: 'Fjern Tittel 1 fra listen' }); + await user.click(button); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveFocus(); + await user.click(button); + await waitFor(() => + expect(screen.queryByRole('link', { name: 'Tittel 1' })).not.toBeInTheDocument(), + ); + expect(log.ToggleList).toHaveLength(1); + }); +}); + describe('MyListPage: feil', () => { it('feil ved «Last flere» gir egen melding, ikke «Kunne ikke fjerne»', async () => { const user = userEvent.setup(); diff --git a/frontend/src/pages/MyListPage.tsx b/frontend/src/pages/MyListPage.tsx index 6ce94d5..e6e74f4 100644 --- a/frontend/src/pages/MyListPage.tsx +++ b/frontend/src/pages/MyListPage.tsx @@ -5,6 +5,7 @@ import { ErrorMessage } from '../components/ErrorMessage'; import { GRID_SIZES, PosterCard } from '../components/PosterCard'; import { PosterSkeleton } from '../components/PosterSkeleton'; import { MY_LIST_QUERY, TOGGLE_LIST_MUTATION } from '../graphql/operations'; +import { useMoreButtonFocus } from '../hooks/useMoreButtonFocus'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; import { rateLimitMessage } from '../lib/apiError'; import { formatNumber } from '../lib/format'; @@ -26,8 +27,11 @@ export default function MyListPage() { const heading = useRef(null); const list = data?.myList; + // «Last flere» forsvinner når hele lista er lastet; da flyttes fokus til overskriften. + const moreFocus = useMoreButtonFocus(list?.pageInfo.hasNextPage ?? false, heading); const remove = async (id: string, name: string) => { + if (removing) return; setRemoveError(''); try { await toggleList({ @@ -107,7 +111,8 @@ export default function MyListPage() { diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index baed25b..581d536 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -387,7 +387,7 @@ fieldset { text-decoration: none; font-weight: 600; } -.btn:hover:not(:disabled) { +.btn:hover:not(:disabled):not([aria-disabled='true']) { border-color: var(--accent-text); } .btn[aria-disabled='true'], @@ -401,7 +401,7 @@ fieldset { color: var(--on-accent); font-weight: 700; } -.btn--primary:hover:not(:disabled) { +.btn--primary:hover:not(:disabled):not([aria-disabled='true']) { filter: brightness(1.08); border-color: var(--accent); } From 3c2048021a584cf5fdfc4aeb51615da4d7ea86bb Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:36:29 +0000 Subject: [PATCH 070/132] fix(search): cap query length at 200 and report facet errors Matches the backend limit (maxLength, clamp in parseSearchState), adds a BAD_USER_INPUT helper, shows an error when facets fail and no longer renders (0) for selected decades before facets have loaded. --- frontend/src/components/FilterPanel.test.tsx | 73 ++++++++++++++++++++ frontend/src/components/FilterPanel.tsx | 15 +++- frontend/src/components/SearchBox.test.tsx | 8 ++- frontend/src/components/SearchBox.tsx | 3 + frontend/src/lib/apiError.ts | 8 +++ frontend/src/lib/focus.ts | 12 ++++ frontend/src/lib/searchState.test.ts | 9 +++ frontend/src/lib/searchState.ts | 13 +++- 8 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/FilterPanel.test.tsx create mode 100644 frontend/src/lib/focus.ts diff --git a/frontend/src/components/FilterPanel.test.tsx b/frontend/src/components/FilterPanel.test.tsx new file mode 100644 index 0000000..00f11dd --- /dev/null +++ b/frontend/src/components/FilterPanel.test.tsx @@ -0,0 +1,73 @@ +import { screen } from '@testing-library/react'; +import { act } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { apiUnavailable } from '../apollo/apiStatus'; +import { FACETS_QUERY } from '../graphql/operations'; +import { EMPTY_STATE } from '../lib/searchState'; +import { FACETS, GENRE_LIST, buildMocks, emptyLog, renderApp } from '../test/utils'; +import { FilterPanel } from './FilterPanel'; + +const noop = () => undefined; + +afterEach(() => act(() => apiUnavailable(false))); + +describe('FilterPanel', () => { + it('viser ikke «(0)» for valgt tiår, type eller sjanger før fasettene er lastet', async () => { + const mocks = buildMocks( + { Facets: () => ({ facets: FACETS }), Genres: () => ({ genres: GENRE_LIST }) }, + emptyLog(), + ); + for (const m of mocks) m.delay = 100; + renderApp( + , + { mocks }, + ); + const decade = screen.getByRole('checkbox', { name: /1980-tallet/ }); + expect(decade).toBeChecked(); + expect(decade.closest('label')).not.toHaveTextContent('(0)'); + expect(screen.getByRole('checkbox', { name: /Horror/ }).closest('label')).not.toHaveTextContent( + '(0)', + ); + expect(screen.getByRole('checkbox', { name: /Film/ }).closest('label')).not.toHaveTextContent( + '(0)', + ); + + // Når fasettene er lastet, er 0 et ekte tall for verdier uten treff. + await screen.findByRole('checkbox', { name: /1990-tallet/ }); + expect( + (await screen.findByRole('checkbox', { name: /1980-tallet/ })).closest('label'), + ).toHaveTextContent('1980-tallet (0)'); + }); + + it('forteller at tallene mangler når fasettene ikke kan hentes (ikke feil stille)', async () => { + renderApp(, { + mocks: [ + { + request: { query: FACETS_QUERY, variables: () => true }, + error: new Error('Noe gikk galt'), + }, + ], + }); + expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke hente antall treff'); + // Selve filtrene virker fortsatt. + expect(screen.getByRole('checkbox', { name: /Serie/ })).toBeInTheDocument(); + }); + + it('overlater feilmeldingen til banneret ved nedetid', async () => { + apiUnavailable(true); + renderApp(, { + mocks: [ + { + request: { query: FACETS_QUERY, variables: () => true }, + error: new Error('Failed to fetch'), + }, + ], + }); + await screen.findByRole('checkbox', { name: /Serie/ }); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index 6872528..3dd2ded 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -1,5 +1,6 @@ import { useId, useState } from 'react'; import { useQuery } from '@apollo/client/react'; +import { useApiUnavailable } from '../apollo/apiStatus'; import { FACETS_QUERY, GENRES_QUERY } from '../graphql/operations'; import type { FacetCount, TitleType } from '../graphql/types'; import { decadeLabel, formatNumber, typeLabel } from '../lib/format'; @@ -23,12 +24,13 @@ function toggle(list: T[], item: T): T[] { export function FilterPanel({ state, onChange }: Props) { const [open, setOpen] = useState(false); + const apiDown = useApiUnavailable(); const panelId = useId(); const ratingId = useId(); const count = activeFilterCount(state); // Fasettene telles mot søketeksten og de ANDRE filtrene (serveren står for det). - const { data, previousData } = useQuery(FACETS_QUERY, { + const { data, previousData, error } = useQuery(FACETS_QUERY, { variables: { query: state.q || null, filters: toFilters(state) }, }); // Forrige fasetter står igjen mens nye hentes, ellers blinker og hopper filterlista. @@ -67,6 +69,13 @@ export function FilterPanel({ state, onChange }: Props) { Filtre{count > 0 ? ` (${count})` : ''} + {/* Ved nedetid sier banneret fra; ellers ville tallene bare forsvunnet uten forklaring. */} + {error && !data && !apiDown && ( +

+ Kunne ikke hente antall treff per filter. Filtrene virker fortsatt. +

+ )} +
))} diff --git a/frontend/src/components/SearchBox.test.tsx b/frontend/src/components/SearchBox.test.tsx index 8ea87b2..bcb70b4 100644 --- a/frontend/src/components/SearchBox.test.tsx +++ b/frontend/src/components/SearchBox.test.tsx @@ -128,6 +128,12 @@ describe('SearchBox', () => { await user.click(input); await user.paste(weird); act(() => vi.advanceTimersByTime(300)); - expect(onCommit).toHaveBeenCalledExactlyOnceWith(weird.trim()); + // Feltet kutter ved 200 tegn (backend avviser lengre), så ingenting over grensen sendes videre. + expect(onCommit).toHaveBeenCalledExactlyOnceWith(weird.slice(0, 200).trim()); + }); + + it('har maxLength 200, samme grense som backend', () => { + const { input } = setup(); + expect(input).toHaveAttribute('maxlength', '200'); }); }); diff --git a/frontend/src/components/SearchBox.tsx b/frontend/src/components/SearchBox.tsx index 080983f..3994958 100644 --- a/frontend/src/components/SearchBox.tsx +++ b/frontend/src/components/SearchBox.tsx @@ -1,5 +1,6 @@ import { useId, useRef, useState, type FormEvent } from 'react'; import { useDebouncedCallback } from '../hooks/useDebouncedCallback'; +import { MAX_QUERY_LENGTH } from '../lib/searchState'; const SEARCH_DEBOUNCE_MS = 300; @@ -56,6 +57,8 @@ export function SearchBox({ value, onCommit }: Props) { value={text} autoComplete="off" spellCheck={false} + // Backend avviser lengre søk; bedre å stoppe det her enn å vise en feil etter at man har limt inn. + maxLength={MAX_QUERY_LENGTH} placeholder="F.eks. The Godfather" onChange={(e) => { setText(e.target.value); diff --git a/frontend/src/lib/apiError.ts b/frontend/src/lib/apiError.ts index ca88aed..9f13285 100644 --- a/frontend/src/lib/apiError.ts +++ b/frontend/src/lib/apiError.ts @@ -15,3 +15,11 @@ export function rateLimitMessage(error: unknown): string | null { const n = Math.ceil(seconds); return `For mange forsøk. Vent ${n} ${n === 1 ? 'sekund' : 'sekunder'} og prøv igjen.`; } + +/** Serveren svarte, men avviste selve inputen (f.eks. for langt søk). Å prøve samme forespørsel igjen hjelper ikke. */ +export function isBadUserInput(error: unknown): boolean { + return ( + CombinedGraphQLErrors.is(error) && + error.errors.some((e) => e.extensions?.code === 'BAD_USER_INPUT') + ); +} diff --git a/frontend/src/lib/focus.ts b/frontend/src/lib/focus.ts new file mode 100644 index 0000000..a735b8a --- /dev/null +++ b/frontend/src/lib/focus.ts @@ -0,0 +1,12 @@ +/** + * Fast punkt å lande på når kontrollen som hadde fokus forsvinner (fjernet filter, nullstilling): + * resultatoverskriften, og sidens h1 om den ikke finnes (f.eks. mens søket feiler). + * Begge har tabindex=-1, så de kan få fokus uten å bli tabb-stopp. + */ +export function focusResults() { + const target = + document.getElementById('results-heading') ?? document.querySelector('main h1'); + if (!target) return; + if (!target.hasAttribute('tabindex')) target.tabIndex = -1; + target.focus({ preventScroll: true }); +} diff --git a/frontend/src/lib/searchState.test.ts b/frontend/src/lib/searchState.test.ts index eae2429..93f4911 100644 --- a/frontend/src/lib/searchState.test.ts +++ b/frontend/src/lib/searchState.test.ts @@ -63,6 +63,15 @@ describe('searchState', () => { expect(toFilters({ ...EMPTY_STATE, available: false })).toBeUndefined(); }); + it('avkorter søketekst over 200 tegn (backend avviser mer), uten å dele et tegn midt i', () => { + expect(parse(`q=${'a'.repeat(300)}`).q).toHaveLength(200); + expect(parse(`q=${'a'.repeat(200)}`).q).toHaveLength(200); + const emoji = parse(`q=${encodeURIComponent('🎬'.repeat(250))}`).q; + expect(Array.from(emoji)).toHaveLength(200); + // Mellomrom i enden etter avkortingen blir også borte, så q er alltid trimmet. + expect(parse(`q=${'a'.repeat(199)}+bbb`).q).toBe('a'.repeat(199)); + }); + it('serialiserer bare det som er satt', () => { expect(serializeSearchState(EMPTY_STATE).toString()).toBe(''); }); diff --git a/frontend/src/lib/searchState.ts b/frontend/src/lib/searchState.ts index f6d7b4b..cdf3a22 100644 --- a/frontend/src/lib/searchState.ts +++ b/frontend/src/lib/searchState.ts @@ -20,6 +20,9 @@ export interface SearchState { dir: SortDirection | null; } +/** Samme grense som backend (validation.ts). Lengre søk avvises der, og brukeren ser da bare en feil. */ +export const MAX_QUERY_LENGTH = 200; + export const EMPTY_STATE: SearchState = { q: '', genres: [], @@ -61,6 +64,14 @@ const list = (value: string | null) => const unique = (items: T[]) => [...new Set(items)]; +/** Teller Unicode-tegn, ikke UTF-16-enheter, slik som backend, så en emoji ikke teller dobbelt. */ +function clampQuery(raw: string): string { + const q = raw.trim(); + if (q.length <= MAX_QUERY_LENGTH) return q; + // En hånd-redigert URL kan inneholde hva som helst; avkorting er bedre enn en avvist forespørsel. + return Array.from(q).slice(0, MAX_QUERY_LENGTH).join('').trim(); +} + export function parseSearchState(params: URLSearchParams): SearchState { const rating = Number(params.get('minRating')); const minRating = @@ -69,7 +80,7 @@ export function parseSearchState(params: URLSearchParams): SearchState { : null; const dir = params.get('dir'); return { - q: (params.get('q') ?? '').trim(), + q: clampQuery(params.get('q') ?? ''), genres: unique(list(params.get('genres'))), decades: unique( list(params.get('decades')) From 9e69fce925fb2823bb1d060d2867e5938c827cd3 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:36:33 +0000 Subject: [PATCH 071/132] fix(a11y): keep focus when filters are removed; harden search pagination Move focus to a neighbouring chip or the results heading when a chip, reset or clear-all control disappears, and to the h1 when the view switches to browse mode. Derive pagination only from the current query's data and key the load-more failure on its cursor so it clears when the search changes. --- frontend/src/components/ActiveFilters.tsx | 38 ++++- .../src/components/SearchResults.test.tsx | 135 +++++++++++++++++- frontend/src/components/SearchResults.tsx | 49 +++++-- frontend/src/hooks/useFocusOnViewSwitch.ts | 21 +++ frontend/src/pages/HomePage.focus.test.tsx | 115 +++++++++++++++ frontend/src/pages/HomePage.tsx | 16 ++- 6 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 frontend/src/hooks/useFocusOnViewSwitch.ts create mode 100644 frontend/src/pages/HomePage.focus.test.tsx diff --git a/frontend/src/components/ActiveFilters.tsx b/frontend/src/components/ActiveFilters.tsx index 62606d5..c1ef8a1 100644 --- a/frontend/src/components/ActiveFilters.tsx +++ b/frontend/src/components/ActiveFilters.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react'; +import { focusResults } from '../lib/focus'; import { decadeLabel, typeLabel } from '../lib/format'; import { activeFilterCount, type SearchState } from '../lib/searchState'; @@ -8,6 +10,28 @@ interface Props { } export function ActiveFilters({ state, onChange, onReset }: Props) { + const list = useRef(null); + // Fjernes en chip, forsvinner knappen som hadde fokus. Fokus går da til nabo-chipen (så man kan fjerne + // flere på rad med tastaturet), og når lista blir tom til resultatoverskriften. Uten dette faller fokus + // til . Verdi: nøkkel til chipen som skal få fokus, 'results', eller null (ingen flytting). + const focusKey = useRef(null); + + // Uten avhengighetsliste: flyttingen skal skje i render-en som følger umiddelbart etter fjerningen, + // når chipen faktisk er borte fra DOM-en. + useEffect(() => { + const target = focusKey.current; + if (target === null) return; + focusKey.current = null; + // Har brukeren (eller et annet element) allerede tatt fokus, lar vi det være. + if (document.activeElement && document.activeElement !== document.body) return; + if (target === 'results') return focusResults(); + const chip = [...(list.current?.querySelectorAll('button[data-chip]') ?? [])].find( + (el) => el.dataset.chip === target, + ); + if (chip) chip.focus(); + else focusResults(); + }); + if (activeFilterCount(state) === 0) return null; const chips: { key: string; label: string; remove: () => void }[] = [ @@ -44,10 +68,18 @@ export function ActiveFilters({ state, onChange, onReset }: Props) { return (
-
    - {chips.map((c) => ( +
      + {chips.map((c, i) => (
    • - diff --git a/frontend/src/components/SearchResults.test.tsx b/frontend/src/components/SearchResults.test.tsx index ad03cfc..43898c8 100644 --- a/frontend/src/components/SearchResults.test.tsx +++ b/frontend/src/components/SearchResults.test.tsx @@ -1,5 +1,7 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { useState } from 'react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { GraphQLError } from 'graphql'; import { describe, expect, it } from 'vitest'; import { SEARCH_QUERY } from '../graphql/operations'; import { EMPTY_STATE } from '../lib/searchState'; @@ -197,3 +199,134 @@ describe('SearchResults', () => { }); }); }); + +describe('SearchResults: fokus og pagineringsfeil', () => { + it('«Last flere» beholder fokus og avvises mens den laster (aria-disabled, ikke disabled)', async () => { + const user = userEvent.setup(); + const log = emptyLog(); + const mocks = buildMocks({ Search: paged }, log); + for (const m of mocks) m.delay = (op) => (op.variables.after ? 100 : 0); + renderApp(, { mocks }); + await screen.findByRole('link', { name: 'Tittel 1' }); + const button = screen.getByRole('button', { name: 'Last flere' }); + await user.click(button); + const busy = screen.getByRole('button', { name: 'Laster …' }); + expect(busy).toBe(button); + expect(busy).toHaveAttribute('aria-disabled', 'true'); + expect(busy).toHaveFocus(); + await user.click(busy); + await screen.findByRole('link', { name: 'Tittel 5' }); + expect(log.Search.filter((v) => v.after)).toHaveLength(1); + }); + + it('flytter fokus til «Viser X av Y» når «Last flere» forsvinner på siste side', async () => { + const user = userEvent.setup(); + renderApp(, { + mocks: buildMocks({ Search: paged }, emptyLog()), + }); + await screen.findByRole('link', { name: 'Tittel 1' }); + await user.click(screen.getByRole('button', { name: 'Last flere' })); + await screen.findByRole('link', { name: 'Tittel 5' }); + expect(screen.queryByRole('button', { name: 'Last flere' })).not.toBeInTheDocument(); + const summary = screen.getByText(/Viser 5 av 5/); + expect(summary).toHaveFocus(); + expect(summary).toHaveAttribute('tabindex', '-1'); + }); + + it('stjeler ikke fokus når siste side kommer uten at knappen hadde fokus', async () => { + const user = userEvent.setup(); + renderApp( + <> + + + , + { mocks: buildMocks({ Search: paged }, emptyLog()) }, + ); + await screen.findByRole('link', { name: 'Tittel 1' }); + const button = screen.getByRole('button', { name: 'Last flere' }); + await user.click(button); + // Brukeren flytter selv fokus videre mens neste side lastes inn. + await act(async () => screen.getByLabelText('Annet felt').focus()); + await screen.findByRole('link', { name: 'Tittel 5' }); + expect(screen.getByLabelText('Annet felt')).toHaveFocus(); + }); + + it('søk over 200 tegn gir en forståelig melding uten «Prøv igjen» (BAD_USER_INPUT)', async () => { + renderApp(, { + mocks: [ + { + request: { query: SEARCH_QUERY, variables: () => true }, + result: { + errors: [ + new GraphQLError('query kan ikke være lengre enn 200 tegn.', { + extensions: { code: 'BAD_USER_INPUT' }, + }), + ], + }, + }, + ], + }); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Søket kan ikke utføres'); + expect(alert).toHaveTextContent('200 tegn'); + expect(alert).not.toHaveTextContent('nettverket'); + expect(screen.queryByRole('button', { name: 'Prøv igjen' })).not.toBeInTheDocument(); + }); + + /** Bytter søk uten å remounte, slik som når URL-en endres på forsiden. */ + function Switchable({ second }: { second: string }) { + const [q, setQ] = useState('første'); + return ( + <> + + + + ); + } + const otherPage = Array.from({ length: 3 }, (_, i) => makeTitle(i + 11)); + const byQuery = (vars: Vars) => + vars.query === 'andre' ? { search: makeConnection(otherPage, 9, true) } : paged(vars); + + it('«Kunne ikke laste flere» blir ikke stående når søket endres', async () => { + const user = userEvent.setup(); + const log = emptyLog(); + const failOnce = { + request: { query: SEARCH_QUERY, variables: (v: Vars) => v.after === 'cursor-tt0000003' }, + error: new Error('Network down'), + }; + renderApp(, { + mocks: buildMocks({ Search: byQuery }, log, [failOnce]), + }); + await screen.findByRole('link', { name: 'Tittel 1' }); + await user.click(screen.getByRole('button', { name: 'Last flere' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke laste flere'); + + await user.click(screen.getByRole('button', { name: 'Bytt søk' })); + await screen.findByRole('link', { name: 'Tittel 11' }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + // ...og det nye søket kan paginere videre med sin egen cursor. + await user.click(screen.getByRole('button', { name: 'Last flere' })); + await waitFor(() => + expect(log.Search.at(-1)).toMatchObject({ query: 'andre', after: 'cursor-tt0000013' }), + ); + }); + + it('«Last flere» finnes ikke mens et nytt søk lastes, og gammel cursor brukes aldri med nye variabler', async () => { + const user = userEvent.setup(); + const log = emptyLog(); + const mocks = buildMocks({ Search: byQuery }, log); + for (const m of mocks) m.delay = (op) => (op.variables.query === 'andre' ? 150 : 0); + renderApp(, { mocks }); + await screen.findByRole('link', { name: 'Tittel 1' }); + await user.click(screen.getByRole('button', { name: 'Bytt søk' })); + + // De forrige treffene står (dempet) igjen, men uten knapp som kunne hentet «side 2» av feil søk. + expect(screen.getByRole('link', { name: 'Tittel 1' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Last flere' })).not.toBeInTheDocument(); + await screen.findByRole('link', { name: 'Tittel 11' }); + expect(screen.getByRole('button', { name: 'Last flere' })).toBeInTheDocument(); + expect(log.Search.filter((v) => v.after)).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/SearchResults.tsx b/frontend/src/components/SearchResults.tsx index c3de6be..b630c25 100644 --- a/frontend/src/components/SearchResults.tsx +++ b/frontend/src/components/SearchResults.tsx @@ -3,8 +3,16 @@ import { useQuery } from '@apollo/client/react'; import { useApiUnavailable } from '../apollo/apiStatus'; import { SEARCH_QUERY } from '../graphql/operations'; import { useInfiniteScroll } from '../hooks/useInfiniteScroll'; +import { useMoreButtonFocus } from '../hooks/useMoreButtonFocus'; +import { isBadUserInput } from '../lib/apiError'; import { formatNumber } from '../lib/format'; -import { activeFilterCount, toFilters, toSort, type SearchState } from '../lib/searchState'; +import { + MAX_QUERY_LENGTH, + activeFilterCount, + toFilters, + toSort, + type SearchState, +} from '../lib/searchState'; import { ErrorMessage } from './ErrorMessage'; import { GRID_SIZES, PosterCard } from './PosterCard'; import { PosterSkeleton } from './PosterSkeleton'; @@ -27,24 +35,33 @@ export function SearchResults({ state, onReset }: Props) { }, }); const [loadingMore, setLoadingMore] = useState(false); - const [moreFailed, setMoreFailed] = useState(false); + // Cursoren «Last flere» feilet på, ikke et ja/nei. Et nytt søk har en annen endCursor, så feilen + // «går ut av seg selv» i stedet for å bli stående og blokkere uendelig scroll for det nye søket. + const [failedCursor, setFailedCursor] = useState(null); const busy = useRef(false); const sentinel = useRef(null); + const status = useRef(null); // Mens nye resultater hentes, står de forrige igjen (dempet) i stedet for å hoppe til skjelett. const result = (data ?? (loading ? previousData : undefined))?.search; - const hasNext = result?.pageInfo.hasNextPage ?? false; - const endCursor = result?.pageInfo.endCursor ?? null; + // Paginering bruker KUN dette søkets egne data. Mens et nytt søk lastes er `result` det forrige søkets + // resultat, og en cursor derfra kombinert med de nye variablene ville gitt feil side (eller feil). + const current = data?.search; + const hasNext = current?.pageInfo.hasNextPage ?? false; + const endCursor = current?.pageInfo.endCursor ?? null; + const moreFailed = failedCursor !== null && failedCursor === endCursor; + // Mens et nytt søk lastes er knappen midlertidig borte uten at siste side er nådd; da skal ikke fokus flyttes. + const moreFocus = useMoreButtonFocus(!current || hasNext, status); const loadMore = async () => { if (busy.current || !hasNext || !endCursor) return; busy.current = true; setLoadingMore(true); - setMoreFailed(false); + setFailedCursor(null); try { await fetchMore({ variables: { after: endCursor } }); } catch { - setMoreFailed(true); + setFailedCursor(endCursor); } finally { busy.current = false; setLoadingMore(false); @@ -54,6 +71,14 @@ export function SearchResults({ state, onReset }: Props) { useInfiniteScroll(sentinel, () => void loadMore(), hasNext && !moreFailed, endCursor); if (error && !result) { + // Serveren avviste selve søket (f.eks. for lang tekst). «Prøv igjen» ville gitt samme svar. + if (isBadUserInput(error)) { + return ( + + ); + } // 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 ( @@ -66,15 +91,15 @@ export function SearchResults({ state, onReset }: Props) { const filtersActive = activeFilterCount(state) > 0; const total = result?.totalCount ?? 0; - const status = !result ? 'Søker …' : `${formatNumber(total)} treff`; + const statusText = !result ? 'Søker …' : `${formatNumber(total)} treff`; return (
      -

      +

      Søkeresultater

      - {status} + {statusText}

      {!result && } @@ -106,7 +131,7 @@ export function SearchResults({ state, onReset }: Props) {
- {canNext && ( + {(canNext || focusedArrow === 'next') && ( diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f0f11de..0be7bd1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -825,6 +825,10 @@ fieldset { line-height: 1; cursor: pointer; } +.row__nav[aria-disabled='true'] { + opacity: 0.4; + cursor: default; +} .row__nav--prev { left: 0; background: linear-gradient(to right, rgb(10 10 12 / 0.9), rgb(10 10 12 / 0)); From 852fb2792ad376a0288c4e2baae58dc04ed4cc61 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:40:47 +0000 Subject: [PATCH 079/132] perf(frontend): load above-the-fold search posters eagerly, first with high priority --- frontend/src/components/PosterCard.test.tsx | 21 +++++++++++++++++-- frontend/src/components/PosterCard.tsx | 16 ++++++++++++-- .../src/components/SearchResults.test.tsx | 18 ++++++++++++++++ frontend/src/components/SearchResults.tsx | 11 ++++++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/PosterCard.test.tsx b/frontend/src/components/PosterCard.test.tsx index 0bc8885..17e0be9 100644 --- a/frontend/src/components/PosterCard.test.tsx +++ b/frontend/src/components/PosterCard.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react'; import { screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { makeTitle, renderApp } from '../test/utils'; @@ -11,10 +12,14 @@ const withPoster = makeTitle(3, { poster342: 'http://img/342.jpg', }); -const renderCard = (title = withPoster, sizes?: string) => +const renderCard = ( + title = withPoster, + sizes?: string, + priority?: ComponentProps['priority'], +) => renderApp(
    - +
, ); @@ -27,6 +32,18 @@ describe('PosterCard', () => { expect(screen.getByRole('article')).toHaveAccessibleName('Heat'); }); + it('priority=high gir eager lasting og fetchpriority=high, eager bare eager', () => { + const { unmount } = renderCard(withPoster, undefined, 'high'); + let img = document.querySelector('img')!; + expect(img).toHaveAttribute('loading', 'eager'); + expect(img).toHaveAttribute('fetchpriority', 'high'); + unmount(); + renderCard(withPoster, undefined, 'eager'); + img = document.querySelector('img')!; + expect(img).toHaveAttribute('loading', 'eager'); + expect(img).not.toHaveAttribute('fetchpriority'); + }); + it('viser plakat med srcset, sizes, lat lasting, mål og tom alt', () => { renderCard(withPoster, GRID_SIZES); const img = document.querySelector('img')!; diff --git a/frontend/src/components/PosterCard.tsx b/frontend/src/components/PosterCard.tsx index 1111964..b912954 100644 --- a/frontend/src/components/PosterCard.tsx +++ b/frontend/src/components/PosterCard.tsx @@ -11,6 +11,11 @@ interface Props { sizes?: string; /** Overskriftsnivå må passe sidens struktur (Min liste har ingen h2 over kortene). */ headingLevel?: 2 | 3; + /** + * Plakater over folden skal ikke være lazy (nettleseren venter ellers med å hente dem til layout + * er ferdig, og LCP blir sen). `high` gir i tillegg fetchpriority=high til den første, som oftest er LCP. + */ + priority?: 'high' | 'eager'; } // Kortene står i to sammenhenger: rader (fast bredde) og rutenett (flytende). @@ -22,7 +27,13 @@ export const GRID_SIZES = '(min-width: 64rem) 12rem, (min-width: 40rem) 30vw, 45 * klikkbart uten at hele innholdet blir lenketekst for skjermlesere, og tastaturfokus * får én tydelig ramme rundt plakaten. */ -export function PosterCard({ title, actions, sizes = ROW_SIZES, headingLevel = 3 }: Props) { +export function PosterCard({ + title, + actions, + sizes = ROW_SIZES, + headingLevel = 3, + priority, +}: Props) { // Samme tittel kan stå i flere rader på samme side, så id-en kan ikke bygge på tittel-id alene. const headingId = useId(); const Heading = `h${headingLevel}` as const; @@ -45,7 +56,8 @@ export function PosterCard({ title, actions, sizes = ROW_SIZES, headingLevel = 3 alt="" width={185} height={278} - loading="lazy" + loading={priority ? 'eager' : 'lazy'} + fetchPriority={priority === 'high' ? 'high' : undefined} decoding="async" /> ) : ( diff --git a/frontend/src/components/SearchResults.test.tsx b/frontend/src/components/SearchResults.test.tsx index ad03cfc..52b5d2f 100644 --- a/frontend/src/components/SearchResults.test.tsx +++ b/frontend/src/components/SearchResults.test.tsx @@ -63,6 +63,24 @@ describe('SearchResults', () => { expect(card.textContent?.replace(/\s/g, ' ')).toContain('1 234 567 stemmer'); }); + it('laster de første plakatene eager (den første med høy prioritet) og resten lazy', async () => { + const titles = Array.from({ length: 8 }, (_, i) => + makeTitle(i + 1, { poster342: `http://img/${i + 1}.jpg` }), + ); + renderApp(, { + mocks: buildMocks({ Search: () => ({ search: makeConnection(titles, 8) }) }, emptyLog()), + }); + await screen.findByRole('link', { name: 'Tittel 1' }); + const imgs = [...document.querySelectorAll('img')]; + expect(imgs.map((img) => img.getAttribute('loading'))).toEqual([ + ...Array(6).fill('eager'), + 'lazy', + 'lazy', + ]); + expect(imgs[0]).toHaveAttribute('fetchpriority', 'high'); + expect(imgs[1]).not.toHaveAttribute('fetchpriority'); + }); + it('formaterer store treffantall med tusenskille', async () => { renderApp(, { mocks: buildMocks( diff --git a/frontend/src/components/SearchResults.tsx b/frontend/src/components/SearchResults.tsx index c3de6be..d78a601 100644 --- a/frontend/src/components/SearchResults.tsx +++ b/frontend/src/components/SearchResults.tsx @@ -10,6 +10,8 @@ import { GRID_SIZES, PosterCard } from './PosterCard'; import { PosterSkeleton } from './PosterSkeleton'; export const PAGE_SIZE = 20; +/** Så mange plakater ligger over folden på vanlige skjermer (2 rader på mobil, 1 på desktop). */ +const EAGER_POSTERS = 6; interface Props { state: SearchState; @@ -100,8 +102,13 @@ export function SearchResults({ state, onReset }: Props) { {result && total > 0 && ( <>
    - {result.edges.map((edge) => ( - + {result.edges.map((edge, i) => ( + ))}
From e99dd234e326989b780b40c61d38842d83418f4e Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:49:28 +0000 Subject: [PATCH 086/132] perf(frontend): render row cards from a deferred value to keep tasks short --- frontend/src/components/TitleRow.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/TitleRow.tsx b/frontend/src/components/TitleRow.tsx index 143d535..a54c662 100644 --- a/frontend/src/components/TitleRow.tsx +++ b/frontend/src/components/TitleRow.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'; +import { + useCallback, + useDeferredValue, + useEffect, + useId, + useRef, + useState, + type KeyboardEvent, +} from 'react'; import { Link } from 'react-router'; import { useApiUnavailable } from '../apollo/apiStatus'; import type { TitleSummary } from '../graphql/types'; @@ -21,7 +29,10 @@ const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; /** Horisontal rad med plakater: scroll-snap, pil-knapper og piltaster mellom kortene. */ -export function TitleRow({ heading, seeAllTo, titles, loading, error, onRetry }: Props) { +export function TitleRow({ heading, seeAllTo, titles: latest, loading, error, onRetry }: Props) { + // Kortene tegnes i en avbrytbar, tidsdelt rendering (ikke i den samme lange oppgaven som mottar dataene), + // så radene ikke blokkerer input like etter første maling. Skjelettet står til kortene er klare. + const titles = useDeferredValue(latest); const headingId = useId(); const apiDown = useApiUnavailable(); const list = useRef(null); From 3278349f8208504223fb542ffd1454a289d24a8d Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:52:30 +0000 Subject: [PATCH 087/132] docs: Lighthouse measurements and bundle sizes after the performance round --- docs/beslutninger.md | 6 ++-- docs/ytelse.md | 72 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/beslutninger.md b/docs/beslutninger.md index 6acea13..3e0f525 100644 --- a/docs/beslutninger.md +++ b/docs/beslutninger.md @@ -78,9 +78,9 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. sortering/sidebytte ikke henter fasetter på nytt. Forrige fasetter vises mens nye lastes. - **Frontend: ny anmeldelse vises via `refetch` av tittelen** etter `addReview` (serveren er kilden til snitt og antall). «Min liste» bruker `cache-and-network` og fjerner rader lokalt i cachen ved «Fjern». -- **Frontend: ingen nye biblioteker.** Målt 2026-10-06 (`npm run build -w frontend`): initial JS ca. 172 kB gzip - (118,3 + 52,5 + 1,2 + 0,4 kB; React, Apollo og React Router) pluss 5,5 kB CSS. Detalj-, spiller- og - listesiden er lazy (4,0 + 3,9 + 1,3 + 0,3 kB, ca. 9,5 kB gzip til sammen). Tidligere tall var ca. 166 kB (2026-09-30). +- **Frontend: ingen nye biblioteker.** Målt 2026-10-06 (`npm run build -w frontend`): initial JS ca. 170 kB gzip + (react 68,3 + apollo 58,8 + router 31,5 + app 10,6 + 0,4 kB; vendor-chunks, se `docs/ytelse.md`) pluss 5,5 kB CSS. Detalj-, spiller-, liste- og + søkevisningen er lazy (3,9 + 4,0 + 1,3 + 2,9 + 0,3 kB, ca. 12,4 kB gzip til sammen). Tidligere tall var ca. 172 kB (2026-10-06 før Lighthouse-runden) og 166 kB (2026-09-30). - **Bruker-ID uten `crypto.randomUUID`.** VM-en serverer appen over http, som ikke er en sikker kontekst, og der finnes ikke `crypto.randomUUID`. Alle GraphQL-requests feilet derfor i produksjon, selv om alle tester (som kjører på `localhost`, som regnes som sikker) var grønne. diff --git a/docs/ytelse.md b/docs/ytelse.md index 3f010e6..7a7d33b 100644 --- a/docs/ytelse.md +++ b/docs/ytelse.md @@ -178,7 +178,77 @@ batcher på 2 000 rader; bare rating-rader over stemmegrensen holdes i minnet. ## Lighthouse -**Målt 2026-09-30, før M7** (oktober-endringene er ikke målt på nytt; `/watch/:id` er ikke målt). Lighthouse (Chromium, headless) mot produksjonsbygget servert av `vite preview` +### Måling 2026-10-06 (etter ytelsesrunden) + +**Oppsett.** Produksjonsbygg (`npm run build -w frontend`) servert av en liten Node-server som etterligner +`deploy/apache-project2.conf`: gzip for samme MIME-typer, samme sikkerhetsheadere og CSP, samme cache-headere, +SPA-fallback, proxy til backend og 404 utenfor `/project2/` (som Apache). Backend i produksjonsmodus med +20 000 syntetiske titler, falsk TMDB (bildene er små SVG-er på localhost) og falsk Archive. Lighthouse 12, +Chromium headless, standard simulert struping (mobil: 4x CPU, simulert 4G; desktop: ingen CPU-struping). +Format: Performance / Accessibility / Best Practices / SEO. Skriptene ligger ikke i repoet; oppsettet er +beskrevet her så det kan gjentas. + +| Side | Mobil før | Mobil etter | Desktop før | Desktop etter | +| -------------------- | ---------------- | ----------------- | -------------------- | --------------- | +| Forside | 76/100/100/91-92 | 93-96/100/100/100 | 99-100/100/100/91-92 | 100/100/100/100 | +| Detalj (`/title/…`) | 82/100/100/91-92 | 98/100/100/100 | 99-100/100/100/91-92 | 100/100/100/100 | +| Min liste | 96/100/100/91-92 | 97/100/100/100 | 99-100/100/100/91-92 | 100/100/100/100 | +| Søk (`/?q=dark`) | 71/100/100/91-92 | 96-97/100/100/100 | 99-100/100/100/91-92 | 100/100/100/100 | +| Spiller (`/watch/…`) | 95/100/100/91-92 | 97/100/100/100 | 99-100/100/100/91-92 | 100/100/100/100 | + +«Før» er `main` før denne grenen (første måling i oppsettet). Etter at serveren også gzippet GraphQL-svar +ga `main` selv 89 på forside og søk på mobil (TBT ca. 300 ms), så en del av forskjellen på forsiden/søk skyldes +Apache-konfigen og ikke bare frontenden. Intervallene (f.eks. 93-96) er variasjon mellom gjentatte kjøringer; +mobil TBT svinger fra ca. 40 til 200 ms på samme bygg. + +**Tiltak som ga effekt** + +- `startTransition` rundt første `root.render` og `useDeferredValue` for radenes titler: React tidsdeler + rendringen (5 ms per bit) i stedet for én lang oppgave. TBT på forsiden mobil gikk fra ca. 300-500 til 70-200 ms. Dette var + det tiltaket som flyttet mobilscoren mest. +- Søkevisningen (filtre, sortering, trefflisten) er lazy-lastet, så forsiden slipper den. Plakatene over folden + på søk er `loading="eager"`, den første med `fetchpriority="high"` (fjernet `lcp-lazy-loaded`). +- Gzip av `application/graphql-response+json` (Apollo 4 ber om den; Yoga svarer med den, og den ble tidligere + sendt ukomprimert) og `text/javascript` i `deploy/apache-project2.conf`. +- `robots.txt` (SEO 91-92 til 100) og tilgjengelig navn på spol-knappene i spilleren (`label-content-name-mismatch`). +- Heltebanneret har fast høyde på mobil (38 rem) og tittel/sammendrag er avkortet med `line-clamp`, så det ikke + vokser når dataene kommer. Målt med Chromium: 608 px både med vanlige og ekstremt lange tekster, ved 320-412 px bredde. +- Statisk app-skall i `index.html` (header og heltebanner-plassholder) og `preconnect` til TMDB. Skallet er + skjult for hjelpemidler, uten lenker, og skiftes ut av `createRoot`. Det gir ekte tidlig maling + (observert FCP ca. 85 ms), men se merknaden om simulert FCP under. +- Vendor-chunks (react, router, apollo) endrer ikke mengden JS (ca. 170 kB gzip initialt), men de endres sjelden og + kan derfor ligge i nettleserens cache etter en deploy. +- `theme-init.js` har en dags cache i Apache-konfigen. `Cross-Origin-Opener-Policy` er fjernet fra konfigen + (ignoreres på http og gir bare konsollmelding). + +**Ærlige merknader** + +- **Simulert FCP står på ca. 2,0 s på mobil, selv om observert FCP er under 100 ms.** Lighthouse' simulering + regner JS-bunten som nødvendig for første maling når den har lastet og kjørt før malingen i sporet. På + localhost skjer alt på noen få millisekunder, så JS rekker det. Et skall som maler før JS er lastet, + ville gitt lavere FCP på et ekte tregt nett, men det kan ikke måles slik her. Vi har ikke forsøkt å lure + målingen (f.eks. forsinke JS kunstig). +- **LCP er ca. 2,3 s på mobil** fordi appen er klient-rendret: HTML, JS, GraphQL-kall, så render av heltebanneret + (`.hero__overview`). Kjeden kan bare kortes ved å forhåndshente spørringen (krever GET-persisted queries) eller + server-rendre, og begge deler er større endringer enn prosjektet tillater. Dette, sammen med TBT, er grunnen til at mobil + Performance ligger på 93-98 og ikke 100. Desktop er 100. +- Lighthouse-revisjonene `unused-javascript`, `render-blocking-resources` (CSS og `theme-init.js`) og + `uses-long-cache-ttl` står igjen som anbefalinger, men telles ikke i scoren. `theme-init.js` må være + blokkerende for å unngå temablink, og CSS er blokkerende med vilje. +- **Best Practices 100 gjelder lokalt.** På VM-en serveres appen over http, og der får Best Practices trekk for + `is-on-https` (og eventuelt andre https-avhengige revisjoner). Det kan ikke fikses uten https på VM-en. +- **`robots.txt`:** Lighthouse leser `/robots.txt` på rotadressen, mens appen ligger under `/project2/`. Filen + (`frontend/public/robots.txt`, `Allow: /`) havner derfor på `/project2/robots.txt`. I målingen svarer serveren 404 + på `/robots.txt`, som Lighthouse behandler som «ingen robots.txt» (OK). På VM-en avhenger resultatet av hva Apache + svarer på rotadressen; en egen `/robots.txt` på VM-roten ligger utenfor repoet. +- Søk på desktop har CLS 0,02 (nye resultater erstatter skjelettet); under grensen på 0,1. +- Detaljsiden: waterfall chunk til spørring ble ikke endret. Detaljsiden scorer 98 på mobil, så + forhåndslasting gir for lite til å forsvare mer kode. +- Prøvd og forkastet: `async` på modulskriptet (ingen målbar effekt på FCP, og det kan kjøre før `#root` finnes). + +### Måling 2026-09-30 (historisk, før M7) + +**Målt 2026-09-30, før M7** (`/watch/:id` ble ikke målt). Lighthouse (Chromium, headless) mot produksjonsbygget servert av `vite preview` og backend med det syntetiske datasettet (120 000 titler). Format: Performance / Accessibility / Best Practices / SEO. From 96b2e5ddfa9b67699ba6ad37c63fe3238c60ef5a Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:02:30 +0000 Subject: [PATCH 088/132] test(e2e): wait for the app to mount before tabbing to the skip link --- e2e/flow.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/flow.spec.ts b/e2e/flow.spec.ts index 8fcccc8..dc0061d 100644 --- a/e2e/flow.spec.ts +++ b/e2e/flow.spec.ts @@ -143,8 +143,11 @@ test('ingen treff og direkte lenke til ukjent side', async ({ page }) => { test('tastatur: skip-link flytter fokus til hovedinnholdet', async ({ page }) => { await page.goto('./'); - await page.keyboard.press('Tab'); const skip = page.getByRole('link', { name: /Hopp til innhold/ }); + // Første render skjer i en transition (main.tsx), og til da vises bare det statiske skallet uten + // fokuserbare elementer. Vent til appen er montert før Tab, slik en bruker ser siden før hen tabber. + await expect(skip).toBeAttached(); + await page.keyboard.press('Tab'); await expect(skip).toBeFocused(); await page.keyboard.press('Enter'); await expect(page.locator('main')).toBeFocused(); From 23ce5432c0a26074fb38d3b4748a9efb55b4bacd Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:05:11 +0000 Subject: [PATCH 089/132] docs: update test counts and Lighthouse results after final review --- README.md | 14 +++++++------- docs/status.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5e96a48..8ea06be 100644 --- a/README.md +++ b/README.md @@ -229,8 +229,8 @@ Målet er WCAG 2.1 AA, og det er verifisert automatisk og med tastatur: - **Visuelt:** synlig fokusring, AA-kontrast i lys og mørk modus, `prefers-reduced-motion` respekteres, og responsivt ned til 320 px uten horisontal scroll. - **Verifisering:** axe kjører i Playwright på alle sider i lys og mørk modus, på desktop og mobil. - Lighthouse Accessibility var 100 på forside, detaljside og Min liste. Det er målt lokalt - 30.09.2026 mot produksjonsbygget med syntetiske data, før endringene i oktober (se + Lighthouse Accessibility, Best Practices og SEO er 100 på alle fem sider, mobil og desktop. Målt + lokalt 07.10.2026 mot produksjonsbygget med Apache-like headere og syntetiske data (se [`docs/ytelse.md`](docs/ytelse.md)). ## Bærekraft @@ -251,8 +251,8 @@ Målet er WCAG 2.1 AA, og det er verifisert automatisk og med tastatur: ingen video selv. - **Mørk modus** er standard i kinodesignet, noe som sparer strøm på OLED-skjermer. Lys modus kan velges. -- **Få avhengigheter:** ingen UI-, spiller- eller ORM-bibliotek. Lighthouse Performance var 94–100 ved - siste lokale måling (30.09.2026, før endringene i oktober). +- **Få avhengigheter:** ingen UI-, spiller- eller ORM-bibliotek. Lighthouse Performance er 100 på + desktop og 95–97 på mobil (simulert treg 4G), målt lokalt 07.10.2026. ## Sikkerhet og robusthet @@ -270,9 +270,9 @@ Målet er WCAG 2.1 AA, og det er verifisert automatisk og med tastatur: | Type | Antall | Kommando | | ----------------------------------------------- | -----: | ------------------ | -| API og database (Vitest mot ekte PostgreSQL) | 415 | `npm test` | -| Komponenter og hooks (Vitest + Testing Library) | 249 | `npm test` | -| E2E med axe (Playwright, desktop og mobil) | 97 | `npm run test:e2e` | +| API og database (Vitest mot ekte PostgreSQL) | 441 | `npm test` | +| Komponenter og hooks (Vitest + Testing Library) | 288 | `npm test` | +| E2E med axe (Playwright, desktop og mobil) | 101 | `npm run test:e2e` | **Typisk bruk** testes med hele flyten: søk, filtrer, sorter, scroll, åpne detalj, skriv anmeldelse og se den i lista, legg i og fjern fra Min liste, og spill en gratisfilm med tastatur. diff --git a/docs/status.md b/docs/status.md index 65a9661..8426954 100644 --- a/docs/status.md +++ b/docs/status.md @@ -25,8 +25,8 @@ stemmer, deployet 2026-09-30). kritiker-agent og ble rettet før merge. - **Tester** (siste verifiserte kjøring, ren E2E-database, CI-modus): - backend: 414–415 tester (Vitest mot testdatabase) - - frontend: 249 komponenttester (Vitest + Testing Library) - - E2E: 97 bestått, 3 hoppet over, 0 ustabile (Playwright, desktop og mobil). De tre hoppede + - frontend: 288 komponenttester (Vitest + Testing Library) + - E2E: 101 bestått, 3 hoppet over, 0 ustabile (Playwright, desktop og mobil). De tre hoppede er statiske CSP-sjekker som bare kjøres på desktop, og én mobil-skip. - **Ytelse:** Lighthouse og `EXPLAIN` i `docs/ytelse.md` (målt før M7, på syntetiske data). - **Deploy:** `deploy/setup-vm.sh`, `deploy/oppdater.sh`, systemd-enhet og Apache-konfig, beskrevet From 1208297e81179a3811b71d8719576cb8f61a44aa Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:54:11 +0000 Subject: [PATCH 090/132] docs: correct backend test count in status --- docs/status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/status.md b/docs/status.md index 8426954..5ced767 100644 --- a/docs/status.md +++ b/docs/status.md @@ -24,7 +24,7 @@ stemmer, deployet 2026-09-30). kommentarer og dokumentasjon (#23). Se `PLAN.md` for PR-nummer. Alle PR-er har review av en kritiker-agent og ble rettet før merge. - **Tester** (siste verifiserte kjøring, ren E2E-database, CI-modus): - - backend: 414–415 tester (Vitest mot testdatabase) + - backend: 441 tester (Vitest mot testdatabase) - frontend: 288 komponenttester (Vitest + Testing Library) - E2E: 101 bestått, 3 hoppet over, 0 ustabile (Playwright, desktop og mobil). De tre hoppede er statiske CSP-sjekker som bare kjøres på desktop, og én mobil-skip. From 6227a33540305f435eb1309724b355813f7a9363 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:56:07 +0000 Subject: [PATCH 091/132] feat(backend): add script that measures search query performance with EXPLAIN ANALYZE --- backend/package.json | 1 + backend/scripts/measure-performance.test.ts | 199 +++++++++++ backend/scripts/measure-performance.ts | 351 ++++++++++++++++++++ deploy/mal-ytelse.sh | 39 +++ 4 files changed, 590 insertions(+) create mode 100644 backend/scripts/measure-performance.test.ts create mode 100644 backend/scripts/measure-performance.ts create mode 100755 deploy/mal-ytelse.sh diff --git a/backend/package.json b/backend/package.json index d913daa..def09f1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "db:seed": "tsx scripts/import-imdb.ts", "db:fixture": "tsx scripts/generate-fixture.ts", "db:artwork": "tsx scripts/prefetch-artwork.ts", + "db:measure": "tsx scripts/measure-performance.ts", "db:archive": "tsx scripts/import-archive.ts", "tmdb:mock": "tsx scripts/tmdb-mock.ts", "archive:mock": "tsx scripts/archive-mock.ts" diff --git a/backend/scripts/measure-performance.test.ts b/backend/scripts/measure-performance.test.ts new file mode 100644 index 0000000..b8cc4e5 --- /dev/null +++ b/backend/scripts/measure-performance.test.ts @@ -0,0 +1,199 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { resetData, TITLE_COUNT } from '../test/fixtures.js'; +import { + capturePool, + describeNode, + explainStatement, + formatReport, + measure, + median, + parsePlan, + SCENARIOS, + type PlanNode, +} from './measure-performance.js'; + +const indexScan: PlanNode = { + 'Node Type': 'Index Scan', + 'Index Name': 'titles_rating_idx', + 'Relation Name': 'titles', + 'Actual Total Time': 0.9, + 'Actual Loops': 1, +}; + +describe('parsePlan', () => { + it('velger noden med mest egentid, ikke den øverste', () => { + const plan = parsePlan([ + { + Plan: { + 'Node Type': 'Limit', + 'Actual Total Time': 1.0, + 'Actual Loops': 1, + Plans: [indexScan], + }, + 'Execution Time': 1.2, + }, + ]); + expect(plan).toEqual({ executionMs: 1.2, mainNode: 'Index Scan using titles_rating_idx' }); + }); + + it('regner tid over alle sløyfer', () => { + const plan = parsePlan([ + { + Plan: { + 'Node Type': 'Nested Loop', + 'Actual Total Time': 10, + 'Actual Loops': 1, + Plans: [ + { + 'Node Type': 'Seq Scan', + 'Relation Name': 'a', + 'Actual Total Time': 1, + 'Actual Loops': 1, + }, + // 0,5 ms x 10 sløyfer = 5 ms, mer enn Seq Scan og Nested Loop selv (4 ms). + { + 'Node Type': 'Index Scan', + 'Index Name': 'b_pkey', + 'Actual Total Time': 0.5, + 'Actual Loops': 10, + }, + ], + }, + 'Execution Time': 10.5, + }, + ]); + expect(plan.mainNode).toBe('Index Scan using b_pkey'); + }); + + it('kaster tydelig feil på uventet format', () => { + expect(() => parsePlan(null)).toThrow(/EXPLAIN/); + expect(() => parsePlan([{ Plan: indexScan }])).toThrow(/Execution Time/); + expect(() => parsePlan([])).toThrow(/EXPLAIN/); + }); +}); + +describe('describeNode', () => { + it('lister indeksene en bitmapskanning kombinerer, uten duplikater', () => { + const node: PlanNode = { + 'Node Type': 'Bitmap Heap Scan', + 'Relation Name': 'titles', + Plans: [ + { + 'Node Type': 'BitmapOr', + Plans: [ + { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'a_trgm' }, + { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'b_trgm' }, + { 'Node Type': 'Bitmap Index Scan', 'Index Name': 'a_trgm' }, + ], + }, + ], + }; + expect(describeNode(node)).toBe('Bitmap Heap Scan on titles (a_trgm, b_trgm)'); + }); + + it('viser sorteringsmetode og tabell når det ikke er noen indeks', () => { + expect(describeNode({ 'Node Type': 'Sort', 'Sort Method': 'top-N heapsort' })).toBe( + 'Sort (top-N heapsort)', + ); + expect(describeNode({ 'Node Type': 'Seq Scan', 'Relation Name': 'titles' })).toBe( + 'Seq Scan on titles', + ); + expect(describeNode({ 'Node Type': 'Aggregate' })).toBe('Aggregate'); + }); +}); + +describe('median', () => { + it('tar midtverdien og ignorerer rekkefølgen', () => { + expect(median([5, 1, 3])).toBe(3); + expect(median([4, 1, 3, 2])).toBe(2.5); + expect(median([7])).toBe(7); + }); + + it('er upåvirket av én ekstrem kjøring', () => { + expect(median([1.1, 1.0, 1.2, 250, 1.1])).toBe(1.1); + }); + + it('kaster på tom liste', () => { + expect(() => median([])).toThrow(); + }); +}); + +describe('formatReport', () => { + it('lager en markdown-tabell og escaper rørtegn i cellene', () => { + const md = formatReport( + { titleCount: 190000, pgVersion: '16.4', date: '2026-10-08', runs: 5, jit: 'off' }, + [ + { name: 'Søk «a|b»', ms: 3.14159, mainNode: 'Index Scan using x', statementCount: 1 }, + { name: 'Fasetter', ms: 120.56, mainNode: 'Seq Scan on titles', statementCount: 4 }, + ], + ); + expect(md).toContain('2026-10-08'); + expect(md).toContain('PostgreSQL 16.4'); + expect(md).toContain('| Scenario | ms (median) | Planens hovednode |'); + expect(md).toContain('| Søk «a\\|b» | 3,14 | Index Scan using x |'); + expect(md).toContain('| Fasetter, sum av 4 spørringer | 120,6 | Seq Scan on titles |'); + }); +}); + +describe('capturePool', () => { + it('fanger SQL og parametere uten å røre en database', async () => { + const { pool, captured } = capturePool(); + await pool.query('SELECT $1', ['x']); + expect(captured).toEqual([{ text: 'SELECT $1', values: ['x'] }]); + }); +}); + +describe('mot testdatabasen', () => { + let pool: Pool; + beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); + }); + afterAll(async () => { + await pool.end(); + }); + + it('måler alle scenarioene og gir en rad per scenario', async () => { + const results = await measure(pool, SCENARIOS, { runs: 2, warmups: 0 }); + expect(results).toHaveLength(SCENARIOS.length); + for (const r of results) { + expect(Number.isFinite(r.ms)).toBe(true); + expect(r.ms).toBeGreaterThanOrEqual(0); + expect(r.mainNode.length).toBeGreaterThan(0); + } + // Fasettene er fire spørringer (sjangre, tiår, typer, tilgjengelige); resten er én. + expect(results.find((r) => r.name.startsWith('Fasetter'))?.statementCount).toBe(4); + expect(results.filter((r) => r.statementCount === 1)).toHaveLength(SCENARIOS.length - 1); + // Fixture-datasettet har langt færre enn 50 sider; tabellen skal si det i stedet for å late som. + expect(TITLE_COUNT).toBeLessThan(50 * 20); + expect(results.find((r) => r.name.startsWith('Side 50'))?.name).toMatch(/rakk bare til side/); + + const md = formatReport( + { titleCount: TITLE_COUNT, pgVersion: '16', date: '2026-10-08', runs: 2, jit: 'off' }, + results, + ); + const tableRows = md + .split('\n') + .filter((l) => l.startsWith('| ') && !l.startsWith('| Scenario')); + // Skilletegnraden («| --- |») kommer i tillegg til én rad per scenario. + expect(tableRows).toHaveLength(SCENARIOS.length + 1); + }); + + it('avviser setninger som ikke er SELECT', async () => { + await expect( + explainStatement(pool, { text: 'DELETE FROM titles', values: [] }), + ).rejects.toThrow(/SELECT/); + const { rows } = await pool.query<{ n: string }>('SELECT count(*) AS n FROM titles'); + expect(Number(rows[0]?.n)).toBe(TITLE_COUNT); + }); + + it('kan måle spørringer med parametere og spesialtegn', async () => { + const plan = await explainStatement(pool, { + text: 'SELECT id FROM titles WHERE primary_title_norm LIKE $1', + values: ["%'; DROP TABLE titles; --%"], + }); + expect(plan.executionMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/backend/scripts/measure-performance.ts b/backend/scripts/measure-performance.ts new file mode 100644 index 0000000..d4237ae --- /dev/null +++ b/backend/scripts/measure-performance.ts @@ -0,0 +1,351 @@ +import { pathToFileURL } from 'node:url'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { getFacets, searchTitles, type SearchArgs, type SortField } from '../src/search.js'; +import type { Filters } from '../src/validation.js'; + +/** + * Måler søkespørringene mot en ekte database og skriver en markdown-tabell til docs/ytelse.md. + * Selve SQL-en kopieres ikke hit: den fanges opp fra search.ts ved å gi funksjonene en «pool» som + * bare skriver ned det den får, slik at tallene alltid gjelder nøyaktig de spørringene API-et + * bygger, også etter at search.ts endres. Kjøres på VM-en via deploy/mal-ytelse.sh. + */ + +export interface Statement { + text: string; + values: unknown[]; +} + +/** Gir search.ts noe som ser ut som en Pool, men som bare registrerer spørringene (og svarer tomt). */ +export function capturePool(): { pool: Pool; captured: Statement[] } { + const captured: Statement[] = []; + const pool = { + query: (text: string, values: unknown[] = []) => { + captured.push({ text, values }); + return Promise.resolve({ rows: [] }); + }, + } as unknown as Pool; + return { pool, captured }; +} + +export interface PlanNode { + 'Node Type': string; + 'Index Name'?: string; + 'Relation Name'?: string; + 'Sort Method'?: string; + 'Actual Total Time'?: number; + 'Actual Loops'?: number; + Plans?: PlanNode[]; +} + +/** Total tid en node brukte (alle sløyfer), inkludert barna. */ +const totalTime = (n: PlanNode) => (n['Actual Total Time'] ?? 0) * (n['Actual Loops'] ?? 1); + +function* walk(node: PlanNode): Generator { + yield node; + for (const child of node.Plans ?? []) yield* walk(child); +} + +/** + * Tid noden brukte selv (uten barna). Hovednoden er den med mest egentid: «Limit» og «Sort» over + * en indeksskanning bruker nesten ingenting selv, og skal ikke bli stående som planens + * «hovednode» bare fordi de ligger øverst. + */ +const selfTime = (n: PlanNode) => + Math.max(0, totalTime(n) - (n.Plans ?? []).reduce((sum, c) => sum + totalTime(c), 0)); + +/** Kort, lesbar beskrivelse som «Index Scan using titles_rating_idx». */ +export function describeNode(node: PlanNode): string { + const type = node['Node Type']; + if (type === 'Bitmap Heap Scan') { + // Selve nytten av en bitmapskanning er hvilke indekser den kombinerer. + const indexes = [...walk(node)] + .map((n) => n['Index Name']) + .filter((name): name is string => Boolean(name)); + const on = node['Relation Name'] ? ` on ${node['Relation Name']}` : ''; + return `${type}${on}${indexes.length ? ` (${[...new Set(indexes)].join(', ')})` : ''}`; + } + if (node['Index Name']) return `${type} using ${node['Index Name']}`; + if (type === 'Sort' && node['Sort Method']) return `${type} (${node['Sort Method']})`; + if (node['Relation Name']) return `${type} on ${node['Relation Name']}`; + return type; +} + +export interface ParsedPlan { + executionMs: number; + mainNode: string; +} + +/** Leser `EXPLAIN (ANALYZE, FORMAT JSON)`-svaret: [{ Plan, "Execution Time", ... }]. */ +export function parsePlan(explainJson: unknown): ParsedPlan { + const root = Array.isArray(explainJson) ? explainJson[0] : undefined; + const plan = root?.Plan as PlanNode | undefined; + const executionMs = root?.['Execution Time']; + if (!plan || typeof executionMs !== 'number') { + throw new Error('Uventet svar fra EXPLAIN: fant ikke Plan og Execution Time.'); + } + let main = plan; + for (const node of walk(plan)) if (selfTime(node) > selfTime(main)) main = node; + return { executionMs, mainNode: describeNode(main) }; +} + +export function median(values: number[]): number { + if (values.length === 0) throw new Error('Median av ingen verdier.'); + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +const READ_ONLY_SQL = /^\s*(SELECT|WITH)\b/i; + +/** + * Kjører EXPLAIN ANALYZE på én setning. ANALYZE kjører spørringen på ekte, så alt som ikke er en ren + * SELECT avvises her i tillegg til at økten er satt til skrivebeskyttet. + */ +export async function explainStatement(db: Pool, stmt: Statement): Promise { + if (!READ_ONLY_SQL.test(stmt.text)) { + throw new Error('Bare SELECT kan måles (EXPLAIN ANALYZE kjører setningen).'); + } + const { rows } = await db.query<{ 'QUERY PLAN': unknown }>( + `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${stmt.text}`, + stmt.values, + ); + return parsePlan(rows[0]?.['QUERY PLAN']); +} + +export const NO_FILTERS: Filters = { + genres: [], + decades: [], + types: [], + minRating: null, + availableOnly: false, +}; + +const PAGE_SIZE = 20; +const DEEP_PAGE = 50; + +interface Captured { + statements: Statement[]; + /** Vises i parentes bak scenarionavnet, f.eks. hvis datasettet var for lite til å nå side 50. */ + note?: string; +} + +export interface Scenario { + name: string; + /** `db` kan brukes til å kjøre ekte spørringer (cursor-paginering); capture-poolen til å fange SQL. */ + capture: (db: Pool) => Promise; +} + +const search = ( + name: string, + query: string | null, + field: SortField, + filters: Filters = NO_FILTERS, +): Scenario => ({ + name, + capture: async () => { + const { pool, captured } = capturePool(); + const args: SearchArgs = { + query, + filters, + field, + direction: 'DESC', + first: PAGE_SIZE, + after: null, + }; + await searchTitles(pool, args); + return { statements: captured }; + }, +}); + +export const SCENARIOS: Scenario[] = [ + search('Søk «dark», relevans', 'dark', 'RELEVANCE'), + search('Søk «dark», rating', 'dark', 'RATING'), + search('Søk «a» (1 tegn, ordprefiks), relevans', 'a', 'RELEVANCE'), + search('Søk «ma» (2 tegn, ordprefiks), relevans', 'ma', 'RELEVANCE'), + search('Søk «the» (vanlig ord), relevans', 'the', 'RELEVANCE'), + search('Søk «amelie» (aksent), relevans', 'amelie', 'RELEVANCE'), + search('Ingen søk, rating, sjanger Drama', null, 'RATING', { ...NO_FILTERS, genres: ['Drama'] }), + { + name: `Side ${DEEP_PAGE} via cursor, rating`, + capture: async (db) => { + // Cursoren kan bare lages av en faktisk forrige side, så de første sidene kjøres på ekte. + let after: string | null = null; + let page = 1; + while (page < DEEP_PAGE) { + const result: Awaited> = await searchTitles(db, { + query: null, + filters: NO_FILTERS, + field: 'RATING', + direction: 'DESC', + first: PAGE_SIZE, + after, + }); + if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor) break; + after = result.pageInfo.endCursor; + page++; + } + const { pool, captured } = capturePool(); + await searchTitles(pool, { + query: null, + filters: NO_FILTERS, + field: 'RATING', + direction: 'DESC', + first: PAGE_SIZE, + after, + }); + return { + statements: captured, + note: page < DEEP_PAGE ? `datasettet rakk bare til side ${page}` : undefined, + }; + }, + }, + { + name: 'totalCount for «the»', + capture: async () => { + const { pool, captured } = capturePool(); + const connection = await searchTitles(pool, { + query: 'the', + filters: NO_FILTERS, + field: 'RELEVANCE', + direction: 'DESC', + first: PAGE_SIZE, + after: null, + }); + // totalCount er lazy: telle-spørringen bygges først nå, akkurat som når klienten ber om feltet. + captured.length = 0; + await connection.totalCount(); + return { statements: captured }; + }, + }, + { + name: 'Fasetter uten filter', + capture: async () => { + const { pool, captured } = capturePool(); + await getFacets(pool, null, NO_FILTERS); + return { statements: captured }; + }, + }, +]; + +export interface ScenarioResult { + name: string; + /** Median av summen av alle setningene i scenarioet, per kjøring. */ + ms: number; + mainNode: string; + statementCount: number; +} + +export const RUNS = 5; + +export interface MeasureOptions { + runs?: number; + warmups?: number; + onProgress?: (message: string) => void; +} + +export async function measure( + db: Pool, + scenarios: Scenario[] = SCENARIOS, + { runs = RUNS, warmups = 1, onProgress = () => {} }: MeasureOptions = {}, +): Promise { + const results: ScenarioResult[] = []; + for (const scenario of scenarios) { + onProgress(scenario.name); + const { statements, note } = await scenario.capture(db); + // Oppvarming slik at tallene gjelder varm cache (som i drift), ikke første lesing fra disk. + for (let i = 0; i < warmups; i++) { + for (const stmt of statements) await explainStatement(db, stmt); + } + const sums: number[] = []; + const perStatement: number[][] = statements.map(() => []); + let lastPlans: ParsedPlan[] = []; + for (let run = 0; run < runs; run++) { + lastPlans = []; + for (const [i, stmt] of statements.entries()) { + const plan = await explainStatement(db, stmt); + lastPlans.push(plan); + perStatement[i]!.push(plan.executionMs); + } + sums.push(lastPlans.reduce((sum, p) => sum + p.executionMs, 0)); + } + // Fasettene sender fire spørringer; planen som vises er den tregeste av dem. + const slowest = perStatement + .map((ms) => median(ms)) + .reduce((best, v, i, all) => (v > all[best]! ? i : best), 0); + results.push({ + name: note ? `${scenario.name} (${note})` : scenario.name, + ms: median(sums), + mainNode: lastPlans[slowest]?.mainNode ?? '(ingen spørring)', + statementCount: statements.length, + }); + } + return results; +} + +export interface ReportInfo { + titleCount: number; + pgVersion: string; + date: string; + runs: number; + jit: string; +} + +const cell = (text: string) => text.replaceAll('|', '\\|'); +const formatMs = (ms: number) => ms.toFixed(ms < 10 ? 2 : 1).replace('.', ','); + +export function formatReport(info: ReportInfo, results: ScenarioResult[]): string { + const rows = results.map((r) => { + const name = r.statementCount > 1 ? `${r.name}, sum av ${r.statementCount} spørringer` : r.name; + return `| ${cell(name)} | ${formatMs(r.ms)} | ${cell(r.mainNode)} |`; + }); + return [ + `**Målt ${info.date}**: ${info.titleCount.toLocaleString('nb-NO')} titler, PostgreSQL ${info.pgVersion}, jit = ${info.jit}.`, + `Median av ${info.runs} kjøringer med \`EXPLAIN (ANALYZE, BUFFERS)\` etter én oppvarming (varm cache). Søkene har \`LIMIT 21\` (= \`first: 20\` + 1).`, + '', + '| Scenario | ms (median) | Planens hovednode |', + '| --- | ---: | --- |', + ...rows, + '', + ].join('\n'); +} + +async function main() { + // Rolig timeout: en kald, uindeksert spørring mot hele datasettet kan ta sekunder, og en + // avbrutt måling sier ingenting. Poolens vanlige 15 s er til for å beskytte API-et. + const pool = createPool(config.databaseUrl, { statementTimeoutMs: 120_000 }); + const client = await pool.connect(); + try { + // Målingen skal aldri kunne endre data, selv om en spørring skulle være feil bygget. + await client.query('SET default_transaction_read_only = on'); + const db = { query: client.query.bind(client) } as unknown as Pool; + const count = await client.query<{ n: string }>('SELECT count(*) AS n FROM titles'); + const version = await client.query<{ server_version: string }>('SHOW server_version'); + const jit = await client.query<{ jit: string }>('SHOW jit'); + const results = await measure(db, SCENARIOS, { + onProgress: (name) => console.error(`Måler: ${name}`), + }); + process.stdout.write( + formatReport( + { + titleCount: Number(count.rows[0]?.n ?? 0), + pgVersion: version.rows[0]?.server_version.split(' ')[0] ?? 'ukjent', + date: new Date().toISOString().slice(0, 10), + runs: RUNS, + jit: jit.rows[0]?.jit ?? 'ukjent', + }, + results, + ), + ); + } finally { + client.release(); + await pool.end(); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/deploy/mal-ytelse.sh b/deploy/mal-ytelse.sh new file mode 100755 index 0000000..c0a8195 --- /dev/null +++ b/deploy/mal-ytelse.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Måler søkeytelsen på det ekte datasettet og skriver en markdown-tabell til stdout, klar til å +# limes inn i docs/ytelse.md: bash deploy/mal-ytelse.sh > ytelse-vm.md +# Fremdrift og feil går til stderr, så fila bare inneholder tabellen. Leser bare fra databasen +# (EXPLAIN ANALYZE på SELECT, i en skrivebeskyttet økt) og kan trygt kjøres mens siden er i bruk, +# men kjør den helst når det er rolig: målingene konkurrerer om CPU med besøkende. +# Skriptet måler en Ubuntu-server; på Windows/macOS ville det feile halvveis. +if [ "$(uname -s)" != "Linux" ] || ! command -v apt-get >/dev/null; then + echo "Dette skriptet skal kjøres på VM-en, ikke på din egen maskin." >&2 + echo "Logg inn først: ssh @it2810-17.idi.ntnu.no" >&2 + exit 1 +fi +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/project2}" +ENV_FILE="$APP_DIR/.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Fant ikke $ENV_FILE. Kjør bash deploy/setup-vm.sh først." >&2 + exit 1 +fi +# Kopien i $APP_DIR oppdateres av setup-vm.sh/oppdater.sh. Er den eldre enn denne fila, mangler +# målerskriptet der og npm gir en uforståelig feil. +if [ ! -f "$APP_DIR/backend/scripts/measure-performance.ts" ]; then + echo "$APP_DIR har ikke målerskriptet ennå. Kjør bash deploy/oppdater.sh først." >&2 + exit 1 +fi + +# Samme uthenting som setup-vm.sh bruker for andre verdier i .env. Passordet i URL-en skrives aldri ut. +DATABASE_URL="$(sed -n 's/^DATABASE_URL=//p' "$ENV_FILE" | tr -d '\r' | tail -n 1)" +if [ -z "$DATABASE_URL" ]; then + echo "DATABASE_URL mangler i $ENV_FILE." >&2 + exit 1 +fi +export DATABASE_URL + +# -s: uten npms egne overskrifter på stdout, som ellers havner i markdown-fila. +cd "$APP_DIR" +npm run -s db:measure -w backend From eb03dc4585336892412b77ff1cd7b9cfc2e04b58 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:56:39 +0000 Subject: [PATCH 092/132] fix(deploy): serve robots.txt from the domain root --- deploy/apache-project2.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf index 2152b4a..cd682b6 100644 --- a/deploy/apache-project2.conf +++ b/deploy/apache-project2.conf @@ -13,6 +13,13 @@ ProxyPassReverse /project2/health http://127.0.0.1:3001/health Alias /project2 /var/www/html/project2 +# Verktøy som Lighthouse og søkemotorer leter etter robots.txt på domeneroten, ikke under +# /project2/. Filen publiseres uansett til /project2/robots.txt (Vite kopierer frontend/public), +# så roten peker på samme fil i stedet for en egen kopi som kunne blitt utdatert. Alias står utenfor +# /, og tilgangen styres av -blokken under, som dekker filen. +# Katalogen må være lik WEB_DIR i deploy/setup-vm.sh. +Alias /robots.txt /var/www/html/project2/robots.txt + Options -Indexes AllowOverride None From 2de78eaf2314b5f2f28cf4f55d1a8696b7eadea2 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:56:40 +0000 Subject: [PATCH 093/132] docs: describe VM performance measurement and root robots.txt --- docs/deploy.md | 2 ++ docs/ytelse.md | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 56f72d8..f8e6cdc 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -82,6 +82,8 @@ Archive-skanning, og til slutt publisering av frontend og reload av Apache. Impo og kan derfor kjøre mens backend svarer, så siden er bare nede mens migreringen går (sekunder, ikke minutter). Skriptet avslutter med feilmelding og avsluttingskode 1 hvis API-et ikke svarer like etter start. +**Måle ytelse på ekte data:** `bash deploy/mal-ytelse.sh > ytelse-vm.md` kjører `EXPLAIN ANALYZE` på søkespørringene og skriver en tabell til `docs/ytelse.md` (se «Måle på VM-en» der). Den bare leser fra databasen. + ## Sikkerhetsheadere og Content-Security-Policy `deploy/apache-project2.conf` setter CSP, `Permissions-Policy`, `Referrer-Policy`, diff --git a/docs/ytelse.md b/docs/ytelse.md index 7a7d33b..265e51d 100644 --- a/docs/ytelse.md +++ b/docs/ytelse.md @@ -176,6 +176,35 @@ søketeksten uten aksent bruker trigramindeksen på de normaliserte kolonnene. 17 s, inkludert oppdatering av alle indekser. Fila strømmes (gunzip + readline) og skrives i batcher på 2 000 rader; bare rating-rader over stemmegrensen holdes i minnet. +## Måle på VM-en + +Tallene over er målt på syntetiske data. For å måle på det ekte datasettet, kjør dette på VM-en +(etter at siste kode er publisert med `bash deploy/oppdater.sh`, slik at `/opt/project2` har skriptet): + +```bash +bash deploy/mal-ytelse.sh > ytelse-vm.md +``` + +Skriptet (`backend/scripts/measure-performance.ts`, `npm run db:measure -w backend`) bygger spørringene med +den samme koden som API-et bruker (`backend/src/search.ts`), kjører `EXPLAIN (ANALYZE, BUFFERS)` på dem i en +skrivebeskyttet økt og skriver en markdown-tabell: median av fem kjøringer etter én oppvarming, og planens +hovednode (noden med mest egentid). Lim tabellen inn under og skriv dato og eventuelle avvik i merknaden. + +> Plassholder: fylles inn av gruppa etter kjøring på VM-en. + +| Scenario | ms (median) | Planens hovednode | +| ------------------------------------------ | ----------: | ----------------- | +| Søk «dark», relevans | | | +| Søk «dark», rating | | | +| Søk «a» (1 tegn, ordprefiks), relevans | | | +| Søk «ma» (2 tegn, ordprefiks), relevans | | | +| Søk «the» (vanlig ord), relevans | | | +| Søk «amelie» (aksent), relevans | | | +| Ingen søk, rating, sjanger Drama | | | +| Side 50 via cursor, rating | | | +| totalCount for «the» | | | +| Fasetter uten filter (sum av 4 spørringer) | | | + ## Lighthouse ### Måling 2026-10-06 (etter ytelsesrunden) @@ -238,9 +267,10 @@ mobil TBT svinger fra ca. 40 til 200 ms på samme bygg. - **Best Practices 100 gjelder lokalt.** På VM-en serveres appen over http, og der får Best Practices trekk for `is-on-https` (og eventuelt andre https-avhengige revisjoner). Det kan ikke fikses uten https på VM-en. - **`robots.txt`:** Lighthouse leser `/robots.txt` på rotadressen, mens appen ligger under `/project2/`. Filen - (`frontend/public/robots.txt`, `Allow: /`) havner derfor på `/project2/robots.txt`. I målingen svarer serveren 404 - på `/robots.txt`, som Lighthouse behandler som «ingen robots.txt» (OK). På VM-en avhenger resultatet av hva Apache - svarer på rotadressen; en egen `/robots.txt` på VM-roten ligger utenfor repoet. + (`frontend/public/robots.txt`, `Allow: /`) havner derfor på `/project2/robots.txt`. I den lokale målingen svarer + serveren 404 på `/robots.txt`, som Lighthouse behandler som «ingen robots.txt» (OK). På VM-en peker + `deploy/apache-project2.conf` rotadressen til samme fil med `Alias /robots.txt`, så `/robots.txt` svarer 200 etter + neste `setup-vm.sh`/`oppdater.sh`. Kan sjekkes med `curl -i http://it2810-17.idi.ntnu.no/robots.txt`. - Søk på desktop har CLS 0,02 (nye resultater erstatter skjelettet); under grensen på 0,1. - Detaljsiden: waterfall chunk til spørring ble ikke endret. Detaljsiden scorer 98 på mobil, så forhåndslasting gir for lite til å forsvare mer kode. From e4a1bc4feabe8172afa7b7a059ddb0e83311cf79 Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:57:00 +0000 Subject: [PATCH 094/132] docs: explain why the app is not server-rendered --- docs/beslutninger.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/beslutninger.md b/docs/beslutninger.md index 02c8cd3..b88c598 100644 --- a/docs/beslutninger.md +++ b/docs/beslutninger.md @@ -301,3 +301,33 @@ FROM (EXCLUDED.kolonner)` hindrer at hver omkjøring lager en ny radversjon per kjøre mens backend svarer. Tjenesten kjører som `project2`, så `.env` får gruppen `project2` og modus 640 i stedet for at hele katalogen skifter eier (importene kjører som deploy-brukeren og skriver til `data/`). Apache lastes på nytt til slutt. + +## Hvorfor ikke SSR + +Appen er en klient-rendret React-app (Vite SPA) som Apache serverer som statiske filer. Vi vurderte +server-rendering, men valgte det bort. + +**Prisen vi betaler.** Lighthouse mobil Performance ligger på 95–97 under simulert 4G og når ikke 100. +Første maling og LCP venter på kjeden HTML, JS, GraphQL-kall og render av heltebanneret. Så lenge innholdet +bygges i nettleseren, kan ikke kjeden kortes ned til mindre enn dette. Desktop er 100. + +**Hvorfor vi ikke gjorde det.** SSR eller streaming (for eksempel React Router i framework mode eller Vite +SSR) ville krevd: + +- Node-rendering av alle ruter (forside, søk, detalj, spiller, min liste), også de som avhenger av + nettleserens anonyme bruker-ID i `localStorage`, som serveren ikke kjenner. +- Hydrering som må gi nøyaktig samme HTML som serveren, med tilhørende feilkilder (hydreringsfeil, flimmer + ved tema og URL-styrt søketilstand). +- Dobbel datahenting eller serialisering av Apollo-cachen fra server til klient, og en server som snakker + med GraphQL-laget i tillegg til nettleseren. +- Endret deploy: i dag er frontend statiske filer bak Apache. Med SSR må en Node-prosess til å kjøre i + tillegg til backend, med egen systemd-enhet, minne- og feilhåndtering på en liten VM. + +Det er mer kompleksitet og flere ting som kan gå galt enn et prosjekt av dette omfanget har nytte av, +for noen få poeng i en syntetisk måling, og tiden gjorde mer nytte på søk, tilgjengelighet og testing. + +**Hva vi gjorde i stedet.** Et statisk app-skall i `index.html` (header og plassholder for heltebanneret), +`startTransition` rundt første render og `useDeferredValue` for radene slik at React tidsdeler arbeidet, +lazy-lastet søkevisning, vendor-chunks som kan ligge i nettleserens cache, `preconnect` til TMDB, fast høyde +på heltebanneret (ingen layoutskift) og gzip av JS, CSS og GraphQL-svar. Mål og tall står i +[`ytelse.md`](ytelse.md#lighthouse). From c895541f66ff9ed3b197eaad821af4a90c8754ae Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:57:06 +0000 Subject: [PATCH 095/132] docs(readme): note http-only VM and client-side rendering as known limitations --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8ea06be..30a07e2 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,10 @@ Historikken skrives aldri om (ingen force-push), og merge-commits viser grenene. - **Ytelsestallene er målt på syntetiske data** (120 000 titler) i utviklingsmiljøet. VM-en har det ekte datasettet. - **Rate limiting ligger i minnet** og nullstilles ved omstart. Det holder for én serverprosess. +- **VM-en kjører http, ikke https.** Lighthouse Best Practices blir derfor lavere der (`is-on-https`), og + brotli og HTTP/2 er ikke tilgjengelig; https krever sertifikat på VM-en. +- **Appen er klient-rendret (ingen SSR).** Mobil Performance i Lighthouse stopper derfor på 95–97, se + [Hvorfor ikke SSR](docs/beslutninger.md#hvorfor-ikke-ssr). ## Videre dokumentasjon From f83dda593826fc92bc8fbf5e3d394ee55bf9923e Mon Sep 17 00:00:00 2001 From: Nicolay Kopaas <150848295+Nicolaykopaas@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:01:35 +0000 Subject: [PATCH 096/132] docs: add walkthrough of how the app works for the group and examiners --- README.md | 2 +- docs/forklaring.md | 597 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 docs/forklaring.md diff --git a/README.md b/README.md index 8ea06be..cf29100 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ Historikken skrives aldri om (ingen force-push), og merge-commits viser grenene. ## Videre dokumentasjon [Oppsett](docs/oppsett.md) · [Deploy](docs/deploy.md) · [API](docs/api.md) · -[Beslutninger](docs/beslutninger.md) · [Ytelse](docs/ytelse.md) · +[Beslutninger](docs/beslutninger.md) · [Ytelse](docs/ytelse.md) · [Forklaring](docs/forklaring.md) · [KI-deklarasjon](docs/ki-deklarasjon.md) · [KI-logg](docs/ki-logg.md) · [Status](docs/status.md) _Bilder og beskrivelser fra TMDB. Produktet bruker TMDB-API-et, men er ikke godkjent eller diff --git a/docs/forklaring.md b/docs/forklaring.md new file mode 100644 index 0000000..721cd15 --- /dev/null +++ b/docs/forklaring.md @@ -0,0 +1,597 @@ +# Slik virker appen + +Dette dokumentet forklarer koden slik at alle på gruppa kan svare på spørsmål fra sensor og +faglærer. Det følger ett søk gjennom hele systemet, går så inn på de delene som er lettest å +spørre om, og ender med en liste over sannsynlige spørsmål med korte svar. + +Henvisningene er `fil:linje` mot gjeldende `claude/adoring-brown-3nct5k`. Linjetall flytter seg når +koden endres; søk etter navnet på funksjonen eller konstanten hvis de ikke stemmer. Installasjon +står i [`oppsett.md`](oppsett.md), valgene bak i [`beslutninger.md`](beslutninger.md) og målingene +i [`ytelse.md`](ytelse.md). + +1. [Én forespørsel fra tastetrykk til skjerm](#1-én-forespørsel-fra-tastetrykk-til-skjerm) +2. [Søket i databasen](#2-søket-i-databasen) +3. [Keyset-paginering](#3-keyset-paginering) +4. [Fasetter og totalCount](#4-fasetter-og-totalcount) +5. [Vern av API-et](#5-vern-av-api-et) +6. [Frontend-tilstand](#6-frontend-tilstand) +7. [Tilgjengelighet i praksis](#7-tilgjengelighet-i-praksis) +8. [Ytelse](#8-ytelse) +9. [Testoppsett](#9-testoppsett) +10. [Sannsynlige sensorspørsmål](#10-sannsynlige-sensorspørsmål) + +## 1. Én forespørsel fra tastetrykk til skjerm + +Brukeren skriver «dark» i søkefeltet på forsiden. + +``` + Nettleser (React + Apollo) VM (it2810-17) + ────────────────────────── ────────────────────────────────────── + SearchBox 300 ms debounce, trim, uendret tekst = ingen request + │ onCommit('dark') + useSearchState setSearchParams → URL /project2/?q=dark (flushSync) + │ URL → parseSearchState → state + SearchResults useQuery(SEARCH_QUERY, { query, filters, sort, first: 20 }) + │ Apollo-cache: nøkkel search({query:"dark"}) – treff = ingen request + │ miss ↓ + userIdLink → apiStatusLink → HttpLink POST /project2/graphql + │ + ▼ + Apache (mod_proxy) ──► Node :3001 (GraphQL Yoga) + legger på X-Forwarded-For │ 1. body ≤ 64 KiB + gzip av svaret ◄────────── │ 2. parse, maks 1000 tokens + │ 3. dybde ≤ 6, kostnad ≤ 2500 + │ 4. kontekst (bruker, IP, loaders) + │ 5. resolver Query.search (validerer) + │ 6. searchTitles → ÉN SQL, LIMIT 21 + ▼ + PostgreSQL (pg_trgm GIN, btree) + Apollo skriver sidene til cachen, normalisert på Title: + │ + SearchResults skjelett → «N treff» (aria-live) → plakatkort + │ IntersectionObserver (400 px før bunnen) → fetchMore({ after: endCursor }) +``` + +1. **Tastetrykk.** `SearchBox` holder teksten i lokal state, så feltet føles direkte + (`frontend/src/components/SearchBox.tsx:63-66`). Hvert tastetrykk starter en 300 ms timer på nytt + (`frontend/src/hooks/useDebouncedCallback.ts:17-18`). Først når brukeren stopper, kjører + `commit` (`SearchBox.tsx:30-36`): teksten trimmes og sammenlignes med det som allerede er meldt + til URL-en. Samme tekst gir ingen request, så «dark » med mellomrom er samme søk som «dark». + Enter avbryter timeren og committer med en gang (`:41-45`). +2. **URL.** `HeaderSearch` kaller `update({ q })` på forsiden og navigerer til `/?q=…` fra andre + sider (`frontend/src/components/HeaderSearch.tsx:19-26`). `useSearchState.update` slår endringen + sammen med resten av URL-en (`frontend/src/hooks/useSearchState.ts:26-35`), og + `parseSearchState` lager `SearchState` av den (`frontend/src/lib/searchState.ts:75`). +3. **Visning.** `HomePage` ser at URL-en ikke lenger er «bla-modus» og laster `SearchView` som egen + JS-chunk (`frontend/src/pages/HomePage.tsx:15,56`). `SearchResults` (`SEARCH_QUERY`) og + `FilterPanel` (`FACETS_QUERY`) starter hver sin `useQuery`: to uavhengige kall i parallell. +4. **Apollo.** Cache-nøkkelen for `search` er bare `query`, `filters` og `sort` + (`frontend/src/apollo/cache.ts:15`). Ligger søket i cachen (f.eks. etter «tilbake»), svarer + Apollo uten nettverk. Ellers går kallet gjennom lenkene (`frontend/src/apollo/client.ts:39`): + `userIdLink` legger på `x-user-id`, `apiStatusLink` fanger nedetid, `HttpLink` sender + `POST /project2/graphql` (adressen bygges av Vites `base`, `client.ts:10`). +5. **Apache.** `ProxyPass /project2/graphql http://127.0.0.1:3001/graphql` + (`deploy/apache-project2.conf:8`). `mod_proxy` legger klientens IP sist i `X-Forwarded-For`. + Svaret komprimeres (`:53`) og får `Cache-Control: no-store` (`:69-70`). +6. **Yoga** (`backend/src/app.ts`), i denne rekkefølgen: kroppen avvises over 64 KiB (`:145`), + dokumentet parses med tak på 1 000 tokens (`:110`), valideringsreglene kjører (`:113-121`: dybde, + antall felt, kostnad), og konteksten bygges per request (`:147-155`, `backend/src/context.ts:31`). +7. **Resolver.** `Query.search` validerer `first` (1–50), `query` (trimmet, maks 200 tegn, ingen + NUL) og filtre, og bruker `RELEVANCE` som standard (`backend/src/resolvers/index.ts:51-66`, + `backend/src/validation.ts:28-45`). +8. **SQL.** `searchTitles` bygger én parametrisert spørring med `LIMIT first+1` + (`backend/src/search.ts:218-240`); `buildPage` kutter den ekstra raden (`:184-205`). +9. **Felt-resolvere.** GraphQL kjører bare resolverne for feltene klienten ba om. `totalCount` gir en + `count(*)` først nå, og plakat- og strømfelt hentes i batch for alle 20 kort (avsnitt 4). +10. **Svar og visning.** Apollo normaliserer hver tittel og slår siden sammen med tidligere sider. + `SearchResults` viser skjelett til data er der, deretter «N treff» i en `aria-live`-region og + kortene (`frontend/src/components/SearchResults.tsx:103,129`). Når sentinel-elementet nærmer + seg viewport, kalles `fetchMore({ after: endCursor })` (`:58-73`, + `frontend/src/hooks/useInfiniteScroll.ts`). + +Tomt søk er ikke et søk: tømmes feltet, forsvinner `q` fra URL-en, `isBrowseState` blir sann og +forsiden viser radene (som ligger i cachen) i stedet for å sende `search(query: "")`. + +## 2. Søket i databasen + +All søking, filtrering og sortering skjer i `backend/src/search.ts`, i SQL, over hele `titles` +(190 607 titler i produksjon). Frontend sorterer eller filtrerer aldri selv. + +### Genererte kolonner + +`backend/migrations/004_unaccent_search.sql:24-29` legger tre lagrede, genererte kolonner på `titles`: + +| Kolonne | Innhold | Brukes av | +| --------------------- | ---------------------------------------------------- | --------------------- | +| `primary_title_norm` | `lower(f_unaccent(primary_title))` | trigram-GIN, `LIKE` | +| `original_title_norm` | `lower(f_unaccent(original_title))` | trigram-GIN, `LIKE` | +| `title_words` | `to_tsvector('simple', )` | ordprefiks (1–2 tegn) | + +**Hvorfor kolonner og ikke et uttrykk i spørringen?** Som indeksuttrykk ville `unaccent()` kjørt på +nytt for hver rad som leses i recheck, filter og relevansberegning. Som `STORED`-kolonne kjøres den +én gang, når raden skrives (ved importen). Prisen er ca. to ganger tittellengden i lagring per rad, +og at migreringen skriver om tabellen under lås (10–20 s på VM, se `docs/deploy.md`). + +**Hvorfor `f_unaccent`?** Postgres krever `IMMUTABLE` i indekser og genererte kolonner, mens +`unaccent()` bare er `STABLE` (ordlisten kan teknisk byttes). `f_unaccent` (`004:11-13`) er en +`IMMUTABLE` innpakning med fullt kvalifisert ordlistenavn. Da blir indeksene lovlige, og +planleggeren kan forhåndsregne `lower(f_unaccent('dark'))` til en konstant slik at indeksen brukes. +Søketeksten normaliseres av **samme uttrykk i Postgres** (`norm`, `search.ts:54`), ikke i +TypeScript, så koden og databasen aldri er uenige om hva «uten aksent» betyr. + +### To veier: ≥ 3 tegn og 1–2 tegn + +`filterConditions` (`search.ts:95-125`) velger vei etter lengde (`isShortQuery`, `:84`). + +- **3 tegn eller mer: trigram-GIN.** `pg_trgm` deler titlene i tretegnssekvenser («dark» → `dar`, + `ark`), og indeksen peker fra hver sekvens til radene. `col LIKE '%dark%'` slår opp trigrammene og + kontrollerer bare kandidatene (`titles_primary_title_trgm_idx` og `…original…`, `004:34-37`). +- **1–2 tegn: ordprefiks med tsvector.** «ma» har ingen trigrammer, så GIN kan ikke brukes og + delstrengsøk ville lest hele tabellen. I stedet søkes `title_words @@ to_tsquery('simple', 'ma:*')` + (`search.ts:80-81`), som finner ord som _begynner_ på «ma» («The Matrix»). `'simple'` betyr ingen + stemming og ingen stoppord, så «the» kan søkes på. Prisen er at «ar» ikke finner «Dark». + +Teksten renses for tegnsetting, mellomrom og kontrolltegn før den settes inn i tsquery-syntaks, +fordi `& | ! ( ) : * < > '` ellers er operatorer eller gir syntaksfeil. Blir ingenting igjen, gir +`NULLIF` NULL og null treff, ikke en feil (`search.ts:75-81`). + +### Normalisering før escaping + +I `LIKE` er `%`, `_` og `\` spesialtegn. Brukeren som søker på «100%» mener et prosenttegn, så de +escapes (`likePattern`, `search.ts:66-67`): + +```sql +'%' || regexp_replace( lower(f_unaccent($1::text)), -- 1. normaliser + '([\\%_])', '\\\1', 'g' ) -- 2. escape %, _ og \ + || '%' +``` + +**Rekkefølgen er sikkerhetskritisk.** Postgres' `unaccent.rules` mapper fullbreddetegn: `%` +(U+FF05) blir `%`, `_` blir `_` og `\` blir `\`. Escapet vi først og normaliserte etterpå, ville +et fullbredde-`%` slippe forbi escapingen, bli et ekte `%` og la søket treffe hele tabellen. +Normaliserer vi først, er alt som er igjen escapet. Testet i `backend/test/search.test.ts:141-160`. +Selve SQL-injeksjonsvernet er et annet lag: alt fra brukeren går som `$n`-parametre +(`class Params`, `search.ts:39-45`). + +### Relevansformelen + +For `RELEVANCE` med 3 tegn eller mer (`search.ts:150-163`): + +``` +relevans = 0,6 · word_similarity(søk, tittel) -- står søkeordene i tittelen? + + 0,2 · similarity(tittel, søk) -- hvor lik er hele tittelen? + + 0,2 · LEAST(log10(stemmer) / 7, 1) -- popularitet, mettet ved 10 millioner +``` + +De to første er `GREATEST` over primær- og originaltittel. Sortering: `relevans DESC, +num_votes DESC, id DESC`. Bare `similarity` ville rangert en obskur tittel som heter «Dark Knight» +(1 500 stemmer) over «The Dark Knight», som brukeren nesten alltid mener. Ved 1–2 tegn er likhet +støy, så da rangeres det bare på stemmer (`:153`). + +### Spørringen, forenklet + +Side 1 for «dark» sortert på relevans, med én sjanger og ett tiår valgt: + +```sql +SELECT t.id, t.primary_title, t.average_rating, t.num_votes, t.genres, …, + (0.6 * GREATEST(word_similarity(q, t.primary_title_norm), word_similarity(q, t.original_title_norm)) + + 0.2 * GREATEST(similarity(t.primary_title_norm, q), similarity(t.original_title_norm, q)) + + 0.2 * LEAST(log(GREATEST(t.num_votes, 1)) / 7.0, 1.0))::float8 AS k0, -- q = norm($2) + t.num_votes AS k1, t.id AS k2 -- cursor-verdier +FROM titles t +WHERE (t.primary_title_norm LIKE '%dark%' -- trigram-GIN (bitmap OR) + OR t.original_title_norm LIKE '%dark%') + AND t.genres @> '{Drama}'::text[] -- GIN, bare hvis sjanger er valgt + AND t.start_decade = ANY('{1990}'::int[]) -- bare hvis tiår er valgt +ORDER BY DESC, t.num_votes DESC, t.id DESC +LIMIT 21; -- first + 1 +``` + +For `RATING`, `YEAR`, `TITLE` og stemmer finnes btree-indekser på nøyaktig samme uttrykk og +kolonnerekkefølge som `ORDER BY` (`001_init.sql:65-68`), så Postgres leser de 21 første radene rett +fra indeksen og stopper. Relevans har ingen indeks: den regnes ut for alle rader trigramfilteret +slipper gjennom (ca. 1 800 for «dark» på syntetiske data) og sorteres med top-N. Derfor er brede +søk («the», ca. 140 ms) det tregeste vi har. + +## 3. Keyset-paginering + +Koden ligger i `backend/src/keyset.ts`, sorteringsnøklene i `sortKeys` (`search.ts:148-181`). + +Klienten sier ikke «gi meg side 7», men «gi meg radene etter denne raden». Raden identifiseres av +verdiene i sorteringsnøkkelen, og SQL bruker **radsammenligning** (`afterCondition`, +`keyset.ts:34-44`): + +```sql +WHERE (COALESCE(t.average_rating, -1), t.num_votes, t.id) < ($4::numeric, $5::int, $6::text) +ORDER BY COALESCE(t.average_rating, -1) DESC, t.num_votes DESC, t.id DESC +LIMIT 21 +``` + +- **Radsammenligning** `(a, b, c) < ($1, $2, $3)` er leksikografisk: først `a`, ved likhet `b`, ved + likhet `c`. Postgres kan da bruke én btree-indeks på `(a, b, c)` og hoppe rett til posisjonen, + i stedet for å evaluere en `OR`-kjede. +- **Alle nøkler går i samme retning** (`keyset.ts:7-9`), ellers fungerer ikke radsammenligningen. +- **`id` er alltid siste nøkkel.** Den er unik, så rekkefølgen er total: to rader kan aldri bytte + plass eller droppes mellom sidene. +- **COALESCE-sentineler.** `NULL < x` er ukjent i SQL, og raden ville stille forsvunnet. Derfor + `COALESCE(average_rating, -1)` og `COALESCE(start_year, 0)` (`search.ts:168,177`). Titler uten + rating eller år er med, men havner sist i `DESC` og først i `ASC`. +- **`first + 1`.** Kommer den ekstra raden, finnes det en neste side + (`hasNextPage = rows.length > first`, `search.ts:192`); den sendes aldri til klienten. + +**Cursor-format.** `base64url(JSON.stringify({ s: signatur, v: verdier }))` (`encodeCursor`, +`keyset.ts:50`). Signaturen er sortering + retning + søketype, f.eks. `search:RATING:DESC:q` +(`q` = vanlig søk, `s` = kort søk på 1–2 tegn, `-` = uten tekst; `search.ts:223-226`). Den er en +etikett som hindrer at en cursor fra én sortering brukes på en annen, ikke en kryptografisk +signatur. `decodeCursor` (`keyset.ts:94-108`) gir `BAD_USER_INPUT` for for lang cursor, ugyldig +base64/JSON, feil signatur, feil antall verdier og verdier av feil type (endelige tall, heltall i +int32, tekst uten NUL, tidsstempler som ekte kalenderdatoer, `:63-74`). Alt valideres i +JavaScript, så en dårlig cursor aldri blir en databasefeil (som ville blitt en maskert 500). + +### Eksempel: to sider + +Søk «dark», sortert på rating (synkende), `first: 2`. Treffene i rekkefølge: + +| # | id | rating | stemmer | +| --- | ----------- | -----: | ------: | +| 1 | `tt0000500` | 8,6 | 50 000 | +| 2 | `tt0000123` | 8,4 | 1 200 | +| 3 | `tt0000077` | 8,4 | 900 | +| 4 | `tt0000040` | 7,1 | 300 | + +**Side 1.** `LIMIT 3` gir rad 1–3. Rad 3 er den ekstra, så `hasNextPage = true`, og bare rad 1 og 2 +sendes. `endCursor` er cursoren til rad 2: + +``` +verdier: [8.4, 1200, "tt0000123"] +cursor: eyJzIjoic2VhcmNoOlJBVElORzpERVNDOnEiLCJ2IjpbOC40LDEyMDAsInR0MDAwMDEyMyJdfQ + = base64url({"s":"search:RATING:DESC:q","v":[8.4,1200,"tt0000123"]}) +``` + +**Side 2.** Klienten sender `after: `. Serveren dekoder, sjekker signaturen og legger til +`AND (COALESCE(t.average_rating, -1), t.num_votes, t.id) < (8.4, 1200, 'tt0000123')`. Rad 3 har +samme rating, men færre stemmer (900 < 1 200), så den kommer med. Hadde den også hatt 1 200 stemmer, +hadde `id` avgjort. Rad 1 og 2 er borte uten at databasen har lest forbi dem. + +### Hvorfor ikke OFFSET + +`OFFSET 10000` betyr at Postgres må finne og kaste 10 000 rader før de 20 vi vil ha, så kostnaden +vokser lineært med hvor langt ned brukeren har scrollet. Legges det inn en ny tittel mens brukeren +leser, skyves alt ett hakk, og en rad vises to ganger (eller forsvinner). Keyset koster det samme +for side 1 som for side 500, og siden er en posisjon i sorteringen, ikke et radnummer. + +## 4. Fasetter og totalCount + +**Fasetter.** `getFacets` (`search.ts:270-335`) kjører fire tellinger parallelt (`Promise.all`): +sjangre, tiår, type og «kan strømmes». Hver bruker `filterConditions` med `skip` satt til sin egen +dimensjon. Eksempel: «dark» med tiår 1990 valgt. Sjangertallene teller titler som matcher «dark» +**og** 1990. Tiårstallene teller «dark» **uten** tiårsfilteret, så brukeren ser hvor mange som +finnes i 2000-årene mens 1990 er avkrysset. Slik viser tallet ved en avkrysningsboks hva man får +innenfor de andre filtrene. Valgte verdier med 0 treff vises likevel (`withSelected`, `:320-323`), +så brukeren kan fjerne dem. Sjangre telles med `CROSS JOIN LATERAL unnest(t.genres)` + `GROUP BY` +(`:298`), tiår med den genererte kolonnen `start_decade` (`001_init.sql:22`). Frontend sender samme +`query` og `filters` som søket (`frontend/src/components/FilterPanel.tsx:33`) og beholder forrige +fasetter mens nye hentes, så lista ikke blinker. Testet i `backend/test/facets.test.ts:59`. + +**totalCount er lazy.** `Connection.totalCount` er en _funksjon_, ikke et tall (`search.ts:35`), og +er `memoize`d (`:253-256`). `TitleConnection.totalCount`-resolveren kaller den +(`resolvers/index.ts:38`), og GraphQL kaller bare resolvere for felt som er med i spørringen. Uten +`totalCount` i spørringen kjører ingen `count(*)`. Forsidens `ROW_QUERY` utelater feltet +(`frontend/src/graphql/operations.ts:71-76`) og sparer åtte helskanninger per visning; +`SEARCH_QUERY` har det, fordi trefflista viser «N treff». Tellingen bruker samme `filterConditions` +uten cursor og `LIMIT`, så den teller hele treffmengden. + +**Batch-loader mot N+1.** Uten tiltak ga en side med 20 titler 20 spørringer for anmeldelsessnitt, +20 for «i min liste», 20 for strøm og så videre. `batchLoader` (`backend/src/loaders.ts:6-35`): +`load(key)` legger nøkkelen i en kø, og første kall planlegger `queueMicrotask(flush)` (`:29`). +GraphQL kaller feltresolverne for alle 20 kort synkront, så alle `load()`-kall rekker å havne i +køen før mikrooppgaven kjører. Da gjøres **én** spørring med `WHERE title_id = ANY($1::text[])` +(`backend/src/context.ts:50-72`). Resultatene caches per instans, og instansene lages per request, +så ingenting lekker mellom brukere. Samme mønster for plakater og strømmer. Testet i +`backend/test/title.test.ts:173` (`describe('N+1')`). + +## 5. Vern av API-et + +Et åpent GraphQL-endepunkt lar klienten bestemme hvor dyr spørringen er. Vi begrenser i lag, fra +billigst (avvis før noe arbeid) til dyrest (sikkerhetsnett): + +| Lag | Grense | Hvor | +| ---------------------- | ------------------------------------------- | --------------------------------- | +| Kroppsstørrelse | 64 KiB | `app.ts:35,145` | +| Tokens i dokumentet | 1 000 | `app.ts:32,110` | +| Dybde | 6 nivå | `app.ts:18`, `depthLimit.ts:15` | +| Rotfelt / felt totalt | 8 / 150 | `app.ts:21-22` | +| Vektet kostnad | 2 500 | `app.ts:26`, `complexityLimit.ts` | +| `first` / søketekst | 1–50 / 200 tegn | `validation.ts:3-4` | +| Rate limit (mutations) | 10 anmeldelser/min, 60 andre/min per bruker | `rateLimit.ts:12` | +| Databasen | `statement_timeout` 15 s, pool på 10 | `db.ts:14-18` | + +**Tokengrensen.** Valideringsregelen `OverlappingFieldsCanBeMerged` i graphql-js er kvadratisk i +antall felt. `{ genres genres … }` med 8 000 felt (55 kB) holdt event-loopen opptatt i ca. 5 s. +Tokengrensen avviser dokumentet under parsing, før noen valideringsregel har kjørt (`app.ts:27-32`). +Frontendens største operasjon har 128 tokens, så det er åtte ganger slingringsmonn. + +**Dybdegrensen** er en sikring mot at en fremtidig sirkulær relasjon (f.eks. `Review.title`) +åpner for uendelig nøsting (`depthLimit.ts:1-14`). Skjemaet nøster ikke dypere enn 6 i dag. + +**Kostnadsgrensen** (`complexityLimit.ts:49-135`). Dybde stopper bare nøsting, men en flat spørring +med hundre aliasede `search(first: 50)` er like dyr. Vektingen: + +- Et listefelt (`search`, `myList`, `reviews`) koster `first` ganger feltene under (`:25`, + `multiplier` `:138-148`). Eksempel: `search(first: 20) { edges { node { id } } }` gir `node = 2`, + `edges = 1 + 1·2 = 3`, `search = 1 + 20·3 = 61`. +- **Variabel `first` regnes alltid som 50** (`:147`), også med standardverdi, fordi klienten kan + overstyre den i `variables`. Ellers kunne `query($n: Int = 1)` sendt med `n = 50` smuget seg forbi. +- `facets` har fast tillegg på 500 (`:32`): hvert kall kjører fire aggregeringer over hele + tabellen. Med grensen 2 500 rommer en spørring fire; frontend sender én. +- Fragmenter memoiseres (`:61-69`). Uten det kan en kjede der hvert ledd spres ti ganger i neste + gi eksponentielt mange besøk, og dermed CPU-DoS under valideringen. + +Forsidens største spørring ligger på ca. 1 100 av 2 500. En vaktbikkjetest (avsnitt 9) sørger for +at det forblir slik. + +**Rate limit** (`backend/src/rateLimit.ts`) gjelder mutations, ikke spørringer. _Token bucket_: +bøtta rommer N tokens, fylles jevnt til full over ett minutt (`:34-57`), og hvert kall koster ett. +En rolig bruker merker aldri grensen, mens en skriptløkke stoppes etter N kall. Svaret er +`RATE_LIMITED` med `retryAfterSeconds`. Det telles både per bruker og per IP (IP-grensen er tre +ganger høyere, fordi en klasse kan dele nett). Minnet er begrenset til 50 000 nøkler (`:28`). + +**Hvilken IP?** Bak Apache ligger klientens IP sist i `X-Forwarded-For`; tidligere ledd kan +klienten ha forfalsket, så bare siste ledd brukes. Og headeren stoles på **bare** når selve +TCP-forbindelsen kommer fra loopback, altså fra Apache (`clientIp`, `rateLimit.ts:125-134`). Kommer +den fra en annen adresse, brukes den adressen. Ellers kunne noen nå port 3001 direkte og sette en +ny falsk IP i hver request. I produksjon lytter backend dessuten bare på `127.0.0.1` +(`backend/src/config.ts:37`). + +**Feilmasking** (`maskError`, `app.ts:169-181`). Egne, forventede feil (`BAD_USER_INPUT`, +`NOT_FOUND`, …) og protokollfeil under HTTP 500 slippes gjennom. Alt annet blir `Intern feil.` +uten SQL, stack eller vertsnavn; detaljene logges bare på serveren. Unntak: er årsaken at +databasen ikke kan nås (`isDatabaseUnavailable`, `dbErrors.ts:15-40`: `ECONNREFUSED`, +Postgres-kode `57P01`/`57P03`/klasse `08`, pool-timeout), returneres `SERVICE_UNAVAILABLE` med en +generell melding. Frontend bruker koden til å velge banner (avsnitt 6). `/health` gjør `SELECT 1` +med 2 s tidsgrense og svarer 200 eller 503 (`app.ts:60-74,127-138`). Introspeksjon og GraphiQL er av +i produksjon (`:41-52,121,157-158`). + +## 6. Frontend-tilstand + +### URL-en er eneste kilde + +Søketekst, filtre og sortering finnes bare i URL-en (`?q=dark&genres=Drama&sort=rating&dir=desc`). +`parseSearchState` leser den og `serializeSearchState` skriver den +(`frontend/src/lib/searchState.ts`). Ingen komponent har egen kopi, så lenker kan deles, «tilbake» +virker og reload gir samme visning. Unntaket er `SearchBox`, som har lokal tekst mens brukeren +skriver (ellers ville debouncen gjort feltet seigt) og synkroniserer fra URL-en hvis den endres +utenfra (`SearchBox.tsx:21-28`). Hvert tastetrykk og hver avkrysning bruker `replace`, så +historikken ikke fylles; første endring fra forsiden bruker `push`, ellers ville «tilbake» hoppet +forbi forsiden (`useSearchState.ts:29`). + +### flushSync-fiksen + +Avkrysningsboksene er _kontrollerte_ av URL-en (`checked={state.types.includes(t)}`). React Router +pakker navigasjon i en React-transition som standard. Mens den pågår, rendrer React den gamle +URL-tilstanden for hastende oppdateringer, så boksen ble satt tilbake til gammel verdi og «hoppet +frem igjen» et øyeblikk etter klikket (også synlig for skjermlesere). `flushSync: true` +(`useSearchState.ts:33`) gjør oppdateringen synkron. Det krever at `RouterProvider` importeres fra +`react-router/dom`, som kobler på `ReactDOM.flushSync` (`frontend/src/main.tsx:4-5`). Testhjelperen +gjør det samme (`frontend/src/test/utils.tsx:7-9`), og regresjonstesten står i +`frontend/src/pages/HomePage.test.tsx:227`. + +### Apollo-cachen + +- **Sider slås sammen.** `relayStylePagination(['query', 'filters', 'sort'])` (`cache.ts:15`) gir én + cache-oppføring per søk, uavhengig av `first` og `after`. `fetchMore` føyer nye kanter til samme + liste, og «tilbake» fra en detaljside viser alle innlastede sider uten request. +- **Titler er normalisert** på `Title:`: samme film i flere rader deler ett cache-objekt, og + `toggleList`/`deleteReview` oppdaterer det ett sted. +- **`ROW_QUERY` uten `totalCount`** deler `search`-nøkkel med `SEARCH_QUERY`. «Se alle» får likevel + ikke antallet fra cachen: feltet mangler, så `SEARCH_QUERY` gir miss og henter første side på nytt + (`operations.ts:71-76`). +- **Ingen blanding av søk.** Mens et nytt søk lastes, vises forrige resultat dempet + (`previousData`, `is-stale`), men paginering bruker bare `data` for gjeldende søk + (`SearchResults.tsx:30-52`). En cursor fra forrige søk kan aldri kombineres med nye variabler. + +### Reaktiv variabel for API-status + +`apiUnavailable` og `apiFailureKind` er `makeVar`s (`frontend/src/apollo/apiStatus.ts:12,19`). En +`ErrorLink` setter dem ved ekte nedetid (`client.ts:20-27`). `classifyApiFailure` +(`apiStatus.ts:30-47`) skiller nettverksfeil og 502/503/504 (`network`) fra `SERVICE_UNAVAILABLE` +(`service`), og ignorerer vanlige GraphQL-feil, siden de betyr at serveren svarte. Første +vellykkede svar nullstiller tilstanden (`client.ts:29`). `ApiUnavailableBanner` viser **ett** +banner, og radene og søket viser da ikke egne feil (`SearchResults.tsx:85`). «Prøv igjen» kjører +`client.refetchQueries({ include: 'active' })` (`ApiUnavailableBanner.tsx:23`). Uten dette ville +åtte rader gitt åtte feil uten å forklare hvorfor. + +## 7. Tilgjengelighet i praksis + +Mål: WCAG 2.1 AA. Det som er lettest å glemme er **fokus** når elementet brukeren står på +forsvinner. Faller fokus til ``, mister tastaturbrukeren plassen og skjermleseren tier. + +- **Fokus til h1 ved rutebytte** (`frontend/src/components/Layout.tsx:27-73`). Etter navigasjon + flyttes fokus til sidens `h1` (`tabIndex = -1`; uten h1 til `
`), så skjermleseren leser opp + hvor man er. Men `h1` finnes ofte ikke ennå, siden siden er lazy-lastet og først viser «Laster …». + Derfor følger en `MutationObserver` (`:45`) med og flytter fokus til gjeldende `h1` ved hver + DOM-endring, **så lenge fokus er der vi la det eller er mistet**. Den **gir opp** (`stop`, `:59`) + så snart brukeren selv gjør noe: `pointerdown`/`keydown` (`:58`), fokus til et annet element + (`:53`), eller etter 3 s (`:68`). Ellers ville den stjålet fokus fra en som begynner å tabbe. Skriver + brukeren i søkefeltet, røres fokus ikke (`:32`). +- **`aria-disabled` i stedet for `disabled`.** Chrome fjerner fokus fra en knapp som blir `disabled`, + så den som trykket «Last flere» med Enter havner på ``. `aria-disabled` beholder fokus og + leses fortsatt som utilgjengelig; klikkhåndtereren vokter selv mot dobbeltkjøring + (`busy.current`, `SearchResults.tsx:59`). Brukes bl.a. i `SearchResults.tsx:153`, `ReviewForm`, + `ApiUnavailableBanner`, `DeleteReviewButton` og `TitleRow` (pilene). +- **Fokus når kontroller fjernes:** + - _Filter-chips_ (`ActiveFilters.tsx:17-32,79`): fokus går til nabo-chipen, så man kan fjerne + flere på rad med tastaturet, og til resultatoverskriften når lista blir tom (`focusResults`, + `frontend/src/lib/focus.ts:6`). + - _Siste filter fjernes_ og visningen blir forsiden: `useFocusOnViewSwitch` flytter fokus til + `h1`, men bare hvis det faktisk er mistet (`HomePage.tsx:55`). «Nullstill alle» gjør det samme + (`SearchView.tsx:19-28`). + - _«Last flere»_ forsvinner på siste side: `useMoreButtonFocus` husker via `onFocus`/`onBlur` at + knappen hadde fokus (etterpå er det for sent å spørre) og flytter det til «Viser X av Y» + (`SearchResults.tsx:56`). + - _Kategorihopp_ (`CategoryNav.tsx:4-18`): fokus til radens ytre `div` med `role="group"` og + `aria-label`, ikke til overskriften, siden raden senere kan bli tom og fjerne den. + - _Sletting av anmeldelse_ (`DeleteReviewButton.tsx:22-30`): knappene byttes ut, så en ref + + effekt flytter fokus først etter at React har committet. +- **`aria-live` for antall treff.** `

N treff

` + (`SearchResults.tsx:103`) er den eneste regionen som leses opp ved nytt søk; seksjonen har + `aria-busy` mens data hentes. Hele trefflista leses ikke opp på nytt. +- **Ett banner.** Nedetid gir ett `role="alert"` (`ApiUnavailableBanner.tsx:39`). Knappen ligger + utenfor alerten, og et mislykket nytt forsøk meldes i en egen `role="status"`, så alerten ikke + leses opp på nytt hver gang teksten endres. +- **Ellers:** `` med synlig label, «Hopp til innhold», kort som `
` med + `aria-labelledby` og tomt `alt` (tittelen står som tekst ved siden av), «Se nå» som tekst og ikke + bare farge, synlig fokusring og `prefers-reduced-motion`. axe kjører i Playwright på alle sider i + lys og mørk modus, desktop og mobil (`e2e/a11y.spec.ts`). + +## 8. Ytelse + +- **Statisk app-skall.** `frontend/index.html` har header og heltebanner-plassholder inne i `#root` + (`:28`ff), så nettleseren kan male før JavaScript har kjørt. `createRoot` erstatter dem. Skallet er + `aria-hidden` og har ingen lenker. `theme-init.js` (`:18`) kjører synkront før første maling og + setter lagret tema og `data-hero`, så siden ikke blinker i feil tema. +- **`startTransition` og `useDeferredValue`.** Første rendering er pakket i `startTransition` + (`main.tsx:19`), så React deler arbeidet i biter på ca. 5 ms og gir kontrollen tilbake til + nettleseren i stedet for én lang oppgave som blokkerer input. `TitleRow` bruker `useDeferredValue` + (`TitleRow.tsx:35`): kortene tegnes i en avbrytbar rendering etter at dataene har kommet, og + skjelettet står til de er klare. +- **Lazy lasting.** Detalj-, spiller- og listesiden er `lazy` (`routes.tsx:10-12`); treffliste og + filtre er en egen chunk som bare lastes ved søk (`HomePage.tsx:15`). Forsideradene under folden + henter først data når de nærmer seg viewport (`LazyRow.tsx:14-17`, `skip: !near`), noe som sparer + både requests og TMDB-oppslag. +- **Vendor-chunks** (`frontend/vite.config.ts:14-30`): `react`, `router` og `apollo` er egne filer. + De endres sjelden, så nettleseren gjenbruker dem etter en ny deploy mens appkoden får nytt hash. +- **Bilder** (`PosterCard.tsx:41-60`): `srcset` med 185 og 342 px, `sizes` etter layout, faste + `width`/`height` (ingen layout-hopp) og `decoding="async"`. De seks første plakatene er `eager` + og den første i tillegg `fetchpriority="high"`; resten er `lazy` (`SearchResults.tsx:22,135`). + `preconnect` mot `image.tmdb.org` (`index.html:12`). Serveren godtar bare kjente bredder + (`POSTER_WIDTHS`, `backend/src/artwork.ts`). +- **Smale spørringer.** Listene henter bare feltene kortet trenger (`SUMMARY_FIELDS`, + `operations.ts:24-25`), ikke `overview` eller bakgrunnsbilder. +- **Caching og komprimering** (`deploy/apache-project2.conf`). `assets/` har innholdshash og caches + i ett år (`immutable`, `:59`), mens `index.html` alltid revalideres. `mod_deflate` komprimerer + `application/graphql-response+json` (`:53`), som er det Apollo Client 4 ber om og Yoga svarer + med; uten den ble hvert GraphQL-svar sendt ukomprimert. + +## 9. Testoppsett + +- **Backend: mot ekte Postgres.** `backend/test/global-setup.ts` dropper skjemaet i testdatabasen og + kjører alle migreringer fra scratch, så migreringene testes også. Testene kaller `yoga.fetch()` + direkte uten nettverksport (`backend/test/helpers.ts:31-50`) og deler én database + (`fileParallelism: false`). Rene enhetstester (`cursor`, `depthLimit`, `complexityLimit`, + `validation`, `dbErrors`) ligger ved koden. `search-index.test.ts:70-90` bruker `EXPLAIN` til å + bevise at trigramindeksen brukes for «dark», ordprefiksindeksen for «da», og at kort søk ikke + regner likhet. +- **Utypisk bruk testes eksplisitt:** tomt søk, ingen treff, SQL-injeksjon, 200 vs 201 tegn, + NUL-tegn, fullbreddetegn, ugyldig og gjenbrukt cursor (`search.test.ts`, `pagination.test.ts`). +- **Frontend: Vitest + Testing Library.** `renderApp` (`frontend/src/test/utils.tsx:99`) bruker + `MockedProvider` med **samme** `createCache()` som produksjon, en minnerouter og + `react-router/dom`. `buildMocks` (`:74`) lager én handler per operasjon som også logger variablene, + så testene kan telle requests (debounce gir ett kall, uendret søk gir null). Nettverksfeil, + tomt resultat og `SERVICE_UNAVAILABLE` har egne tester (`HomePage.offline.test.tsx`). +- **E2E: Playwright**, desktop og mobil (Pixel 7). `playwright.config.ts` starter falsk TMDB (3999), + falsk Internet Archive (3998), den bygde backenden og `vite preview` av frontend. Preview sender + **samme CSP som Apache**: `e2e/csp-policy.ts` leser den fra `deploy/apache-project2.conf` med + verter byttet mot mock-ene, så et nytt eksternt opphav gir testbrudd i stedet for feil i + produksjon. `global-setup.ts` migrerer en egen E2E-database, fyller den med 20 000 syntetiske + titler og tømmer anmeldelser og lister før hver kjøring. Ingen test trenger internett. +- **`failOnFlakyTests`** (`playwright.config.ts:22`): i CI får en test ett nytt forsøk for å gi + trace, men består den bare på forsøk to, teller den som feil. Ustabile tester skal fikses. +- **Vaktbikkjetesten** (`backend/test/server.test.ts:115-178`) leser `frontend/src/graphql/operations.ts` + _som tekst_, trekker ut hver `gql`-mal med regex og validerer dem mot skjemaet med samme regler og + grenser som `createApp`. Den sjekker også at antall funnede operasjoner er lik antall + `gql`-maler i fila (en ny operasjon i et format regexen ikke kjenner kan ikke gli forbi), og at + hver holder seg under en fjerdedel av tokengrensen. Feiler den, er skjemaet endret uten at + klienten fulgte med, eller grensene er for stramme for den faktiske klienten. +- **Omfang** (siste verifiserte kjøring, `docs/status.md`): ca. 415 backendtester, 288 + komponenttester og 101 bestått E2E. CI (`.gitlab-ci.yml`) kjører lint, typecheck, enhetstester + mot Postgres 16, build og E2E. + +## 10. Sannsynlige sensorspørsmål + +**1. Hvorfor skalerer dette til en udefinert stor datamengde?** +Ingenting leser hele datasettet i hovedløypa. Søk, filter og sortering er indeksdrevne, hver side +er maks 50 rader, og keyset-paginering koster det samme uansett hvor langt ned man er. Svarets +størrelse er uavhengig av tabellstørrelsen. Det som vokser med data er `count(*)` og +relevanssortering, som er lineære i antall _treff_ (ikke tabellen), og de kjøres bare når de +trengs. Ærlig: svært brede søk («the») er dyrest (ca. 140 ms ved 120 000 titler). Ved mye større +data ville vi cachet eller estimert tellingen. + +**2. Hva skjer om to titler har lik rating?** +Sorteringsnøkkelen er `(rating, stemmer, id)`. Ved lik rating avgjør stemmer, ved lik begge avgjør +`id`, som er unik. Rekkefølgen er total og stabil, så ingen rad dukker opp to ganger eller +forsvinner mellom sidene (avsnitt 3). + +**3. Hvorfor ikke `ILIKE`?** +`ILIKE '%dark%'` på råkolonnen kan ikke bruke en vanlig indeks og leser hele tabellen. Vi bruker +`LIKE` mot en normalisert kolonne (`lower` + `unaccent`) med trigram-GIN. Det er raskere, og det +gjør søket aksentuavhengig («amelie» finner «Amélie»). + +**4. Hvorfor ikke SSR?** +Appen er en interaktiv søkeflate som serveres som statiske filer bak Apache. Sidene trenger ikke +indekseres av søkemotorer, og SSR ville krevd en Node-renderer i drift og hydrering for liten +gevinst. Det SSR ville gitt, rask første maling, har vi dekket med statisk app-skall, lazy chunks +og Lighthouse Performance 100 (`docs/ytelse.md`). Svakheten: innholdet finnes ikke uten JavaScript +(`