From 8b87e5c2b83d3b08980238f4fd20bfff567c7267 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:31:04 +0200 Subject: [PATCH 1/6] feat(backend): legg til createdAt i Listing-typen Co-authored-by: tordtst --- backend/src/db/listingsRepository.ts | 3 ++- backend/src/graphql/schema.ts | 2 ++ backend/src/types/listing.ts | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/db/listingsRepository.ts b/backend/src/db/listingsRepository.ts index 8bd74b2..94cc145 100644 --- a/backend/src/db/listingsRepository.ts +++ b/backend/src/db/listingsRepository.ts @@ -14,7 +14,8 @@ const LISTING_COLUMNS = ` daily_price, condition, accessories, - image_url + image_url, + created_at `; export async function findAllListings(): Promise { diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index da5b525..2c0c3fb 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -24,6 +24,8 @@ export const typeDefs = `#graphql condition: ToolCondition! accessories: [String!]! imageUrl: String + "Når annonsen ble opprettet, som ISO 8601-tekst i UTC." + createdAt: String! } input CreateListingInput { diff --git a/backend/src/types/listing.ts b/backend/src/types/listing.ts index 5f8dd44..8cc2ad2 100644 --- a/backend/src/types/listing.ts +++ b/backend/src/types/listing.ts @@ -15,6 +15,8 @@ export interface Listing { condition: ToolCondition; accessories: string[]; imageUrl: string | null; + /** Når annonsen ble opprettet, som ISO 8601-tekst i UTC. */ + createdAt: string; } /** Rad fra PostgreSQL-tabellen listings. */ @@ -28,6 +30,8 @@ export interface ListingRow { condition: string; accessories: string[]; image_url: string | null; + /** pg gjør timestamptz om til Date. */ + created_at: Date; } export function listingFromRow(row: ListingRow): Listing { @@ -41,5 +45,6 @@ export function listingFromRow(row: ListingRow): Listing { condition: row.condition as ToolCondition, accessories: row.accessories, imageUrl: row.image_url, + createdAt: row.created_at.toISOString(), }; } From bc4048bf427aa3b5c3766766148a26f33a5d6f2d Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:31:25 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(backend):=20valider=20sidest=C3=B8rrel?= =?UTF-8?q?se=20og=20posisjon=20for=20listings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: birkjr --- backend/src/validation/paginationArgs.test.ts | 54 +++++++++++++++++++ backend/src/validation/paginationArgs.ts | 50 +++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 backend/src/validation/paginationArgs.test.ts create mode 100644 backend/src/validation/paginationArgs.ts diff --git a/backend/src/validation/paginationArgs.test.ts b/backend/src/validation/paginationArgs.test.ts new file mode 100644 index 0000000..6d00dfb --- /dev/null +++ b/backend/src/validation/paginationArgs.test.ts @@ -0,0 +1,54 @@ +import { GraphQLError } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + validatePaginationArgs, +} from './paginationArgs.js'; + +function expectBadUserInput(run: () => unknown) { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(GraphQLError); + expect((error as GraphQLError).extensions.code).toBe('BAD_USER_INPUT'); + return; + } + throw new Error('Forventet BAD_USER_INPUT, men ingen feil ble kastet.'); +} + +describe('validatePaginationArgs', () => { + it('bruker standardverdier når ingenting er oppgitt', () => { + expect(validatePaginationArgs({})).toEqual({ + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }); + }); + + it('behandler null som ikke oppgitt', () => { + expect(validatePaginationArgs({ limit: null, offset: null })).toEqual({ + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }); + }); + + it('godtar grenseverdiene', () => { + expect(validatePaginationArgs({ limit: 1, offset: 0 })).toEqual({ + limit: 1, + offset: 0, + }); + expect(validatePaginationArgs({ limit: MAX_PAGE_SIZE })).toEqual({ + limit: MAX_PAGE_SIZE, + offset: 0, + }); + }); + + it.each([0, -1, MAX_PAGE_SIZE + 1])('avviser limit %i', (limit) => { + expectBadUserInput(() => validatePaginationArgs({ limit })); + }); + + it('avviser negativ offset', () => { + expectBadUserInput(() => validatePaginationArgs({ offset: -1 })); + }); +}); diff --git a/backend/src/validation/paginationArgs.ts b/backend/src/validation/paginationArgs.ts new file mode 100644 index 0000000..0726e97 --- /dev/null +++ b/backend/src/validation/paginationArgs.ts @@ -0,0 +1,50 @@ +import { GraphQLError } from 'graphql'; + +/** Antall annonser per side når klienten ikke ber om noe annet. */ +export const DEFAULT_PAGE_SIZE = 24; +/** + * Øvre grense for én side. Hindrer at en klient henter hele tabellen i én + * spørring, noe som blir tregere og tyngre jo flere annonser databasen har. + */ +export const MAX_PAGE_SIZE = 100; + +export interface PaginationArgs { + limit?: number | null; + offset?: number | null; +} + +export interface ValidatedPaginationArgs { + limit: number; + offset: number; +} + +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +/** + * Validerer sidestørrelse og posisjon for listings. GraphQL sikrer allerede at + * verdiene er heltall, så her sjekkes bare grensene. null behandles som at + * verdien ikke er oppgitt. + */ +export function validatePaginationArgs( + args: PaginationArgs, +): ValidatedPaginationArgs { + const limit = args.limit ?? DEFAULT_PAGE_SIZE; + const offset = args.offset ?? 0; + + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) { + invalidInput( + `Sidestørrelsen (limit) må være et heltall fra 1 til ${MAX_PAGE_SIZE}.`, + ); + } + if (!Number.isInteger(offset) || offset < 0) { + invalidInput( + 'Posisjonen (offset) må være et heltall som er 0 eller større.', + ); + } + + return { limit, offset }; +} From 8f95eca25819d201662467bb17b2330dea597c1c Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:32:09 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(backend):=20legg=20til=20paginering=20?= =?UTF-8?q?i=20listings-sp=C3=B8rringen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: andretg --- backend/src/db/listingsRepository.ts | 41 ++++++++++++++++++++++--- backend/src/graphql/graphql.api.test.ts | 26 +++++++++------- backend/src/graphql/resolvers.ts | 9 ++++-- backend/src/graphql/schema.ts | 14 ++++++++- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/backend/src/db/listingsRepository.ts b/backend/src/db/listingsRepository.ts index 94cc145..d9a1b76 100644 --- a/backend/src/db/listingsRepository.ts +++ b/backend/src/db/listingsRepository.ts @@ -18,11 +18,42 @@ const LISTING_COLUMNS = ` created_at `; -export async function findAllListings(): Promise { - const result = await pool.query( - `SELECT ${LISTING_COLUMNS} FROM listings ORDER BY id`, - ); - return result.rows.map(listingFromRow); +export interface ListingPage { + items: Listing[]; + totalCount: number; + hasNextPage: boolean; +} + +/** + * Henter én side med annonser, nyeste først. id er andre sorteringsnøkkel, + * fordi mange annonser kan ha samme created_at. Uten den kunne PostgreSQL + * returnere like tidspunkt i ulik rekkefølge fra gang til gang, og en annonse + * kunne blitt vist på to sider eller ikke i det hele tatt. + */ +export async function findListingsPage( + limit: number, + offset: number, +): Promise { + const [itemsResult, countResult] = await Promise.all([ + pool.query( + `SELECT ${LISTING_COLUMNS} + FROM listings + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2`, + [limit, offset], + ), + pool.query<{ total: string }>( + 'SELECT COUNT(*)::text AS total FROM listings', + ), + ]); + + const totalCount = Number(countResult.rows[0]?.total ?? 0); + + return { + items: itemsResult.rows.map(listingFromRow), + totalCount, + hasNextPage: offset + limit < totalCount, + }; } export async function findListingById(id: string): Promise { diff --git a/backend/src/graphql/graphql.api.test.ts b/backend/src/graphql/graphql.api.test.ts index e99defa..6b3a361 100644 --- a/backend/src/graphql/graphql.api.test.ts +++ b/backend/src/graphql/graphql.api.test.ts @@ -71,15 +71,18 @@ describe('GraphQL API', () => { const response = await server.executeOperation({ query: `query { listings { - id - title - category - description - area - dailyPrice - condition - accessories - imageUrl + items { + id + title + category + description + area + dailyPrice + condition + accessories + imageUrl + createdAt + } } }`, }); @@ -90,8 +93,9 @@ describe('GraphQL API', () => { } expect(response.body.singleResult.errors).toBeUndefined(); - const listings = response.body.singleResult.data?.listings as - Record[] | undefined; + const page = response.body.singleResult.data?.listings as + { items: Record[] } | undefined; + const listings = page?.items; expect(Array.isArray(listings)).toBe(true); expect(listings!.length).toBeGreaterThan(0); diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index a672efe..47adb47 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,15 +1,20 @@ import { - findAllListings, findListingById, + findListingsPage, insertListing, } from '../db/listingsRepository.js'; import type { CreateListingInput } from '../validation/createListingInput.js'; import { validateCreateListingInput } from '../validation/createListingInput.js'; +import type { PaginationArgs } from '../validation/paginationArgs.js'; +import { validatePaginationArgs } from '../validation/paginationArgs.js'; export const resolvers = { Query: { health: () => 'ok' as const, - listings: () => findAllListings(), + listings: (_parent: unknown, args: PaginationArgs) => { + const { limit, offset } = validatePaginationArgs(args); + return findListingsPage(limit, offset); + }, listing: (_parent: unknown, args: { id: string }) => findListingById(args.id), }, diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 2c0c3fb..559ff94 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -28,6 +28,14 @@ export const typeDefs = `#graphql createdAt: String! } + "Én side med annonser og totalt antall treff." + type ListingPage { + items: [Listing!]! + "Antall annonser totalt, ikke bare på denne siden." + totalCount: Int! + hasNextPage: Boolean! + } + input CreateListingInput { title: String! category: ToolCategory! @@ -38,7 +46,11 @@ export const typeDefs = `#graphql type Query { health: String! - listings: [Listing!]! + """ + Annonser sortert med nyeste først. limit er sidestørrelsen (1–100) og + offset er hvor mange annonser som hoppes over. + """ + listings(limit: Int = 24, offset: Int = 0): ListingPage! listing(id: ID!): Listing } From c99b2860055e0a0ac62acdfe8e5f88dc708bf04a Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:32:58 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(backend):=20legg=20til=20indeks=20for?= =?UTF-8?q?=20sortering=20p=C3=A5=20nyeste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tordtst --- ...006352081_add-listings-created-at-index.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/migrations/1792006352081_add-listings-created-at-index.ts diff --git a/backend/migrations/1792006352081_add-listings-created-at-index.ts b/backend/migrations/1792006352081_add-listings-created-at-index.ts new file mode 100644 index 0000000..ae856bf --- /dev/null +++ b/backend/migrations/1792006352081_add-listings-created-at-index.ts @@ -0,0 +1,24 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Indeks for standardsorteringen i listings: nyeste først, med id som andre + * nøkkel. Indeksen har samme rekkefølge som ORDER BY created_at DESC, id DESC, + * så PostgreSQL kan lese den første siden rett fra indeksen i stedet for å + * sortere hele tabellen for hver forespørsel. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.createIndex( + 'listings', + [ + { name: 'created_at', sort: 'DESC' }, + { name: 'id', sort: 'DESC' }, + ], + { name: 'listings_created_at_id_idx' }, + ); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropIndex('listings', [], { name: 'listings_created_at_id_idx' }); +} From bfdf6ab69a2c24fbfa0f76e8cbd9217d613862d4 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:34:03 +0200 Subject: [PATCH 5/6] =?UTF-8?q?test(backend):=20test=20paginering=20i=20li?= =?UTF-8?q?stings-sp=C3=B8rringen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: birkjr --- .../graphql/listingsPagination.api.test.ts | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 backend/src/graphql/listingsPagination.api.test.ts diff --git a/backend/src/graphql/listingsPagination.api.test.ts b/backend/src/graphql/listingsPagination.api.test.ts new file mode 100644 index 0000000..205763c --- /dev/null +++ b/backend/src/graphql/listingsPagination.api.test.ts @@ -0,0 +1,156 @@ +import type { ApolloServer } from '@apollo/server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + closeTestPool, + createTestApolloServer, +} from '../test/apolloTestServer.js'; +import { hasTestDatabase } from '../test/env.js'; +import { MAX_PAGE_SIZE } from '../validation/paginationArgs.js'; + +interface PageItem { + id: string; + createdAt: string; +} + +interface Page { + items: PageItem[]; + totalCount: number; + hasNextPage: boolean; +} + +/** + * Tre annonser med nøyaktig samme tidspunkt, lenger fram i tid enn resten, så + * de havner først. Seed-annonsene har ulike tidspunkt ned på millisekundet, så + * uten disse ville rekkefølgen ved like tidspunkt aldri blitt testet. + */ +const TIED_IDS = ['test-page-a', 'test-page-b', 'test-page-c']; +const TIED_CREATED_AT = '2099-01-01T00:00:00.000Z'; + +const LISTINGS_QUERY = `query ($limit: Int, $offset: Int) { + listings(limit: $limit, offset: $offset) { + totalCount + hasNextPage + items { id createdAt } + } +}`; + +describe.skipIf(!hasTestDatabase)('listings med paginering', () => { + let server: ApolloServer; + + beforeAll(async () => { + server = await createTestApolloServer(); + const { pool } = await import('../db/pool.js'); + for (const id of TIED_IDS) { + await pool.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition, created_at) + VALUES ($1, 'Testannonse', 'handTools', 'Test av paginering.', 'Moholt', 0, 'good', $2) + ON CONFLICT (id) DO NOTHING`, + [id, TIED_CREATED_AT], + ); + } + }); + + afterAll(async () => { + const { pool } = await import('../db/pool.js'); + await pool.query('DELETE FROM listings WHERE id = ANY($1)', [TIED_IDS]); + await server.stop(); + await closeTestPool(); + }); + + async function runListings(variables: { limit?: number; offset?: number }) { + const response = await server.executeOperation({ + query: LISTINGS_QUERY, + variables, + }); + if (response.body.kind !== 'single') { + throw new Error('Forventet ett svar fra GraphQL.'); + } + return response.body.singleResult; + } + + async function fetchPage(limit: number, offset: number): Promise { + const result = await runListings({ limit, offset }); + expect(result.errors).toBeUndefined(); + return result.data?.listings as Page; + } + + it('gir første side med riktig størrelse og totalt antall', async () => { + const page = await fetchPage(3, 0); + expect(page.totalCount).toBeGreaterThan(3); + expect(page.items).toHaveLength(3); + expect(page.hasNextPage).toBe(true); + }); + + it('bruker 24 som standard sidestørrelse', async () => { + const result = await runListings({}); + expect(result.errors).toBeUndefined(); + const page = result.data?.listings as Page; + expect(page.items).toHaveLength(Math.min(24, page.totalCount)); + }); + + it('gir resten av annonsene på siste side', async () => { + const limit = 4; + const { totalCount } = await fetchPage(1, 0); + const lastOffset = Math.floor((totalCount - 1) / limit) * limit; + + const page = await fetchPage(limit, lastOffset); + expect(page.items).toHaveLength(totalCount - lastOffset); + expect(page.hasNextPage).toBe(false); + }); + + it('gir tom side, men riktig totalt antall, utenfor treffmengden', async () => { + const { totalCount } = await fetchPage(1, 0); + const page = await fetchPage(5, totalCount + 10); + expect(page.items).toEqual([]); + expect(page.totalCount).toBe(totalCount); + expect(page.hasNextPage).toBe(false); + }); + + it('viser hver annonse nøyaktig én gang, nyeste først, på tvers av sider', async () => { + const limit = 3; + const { totalCount } = await fetchPage(1, 0); + const all: PageItem[] = []; + for (let offset = 0; offset < totalCount; offset += limit) { + all.push(...(await fetchPage(limit, offset)).items); + } + + expect(all).toHaveLength(totalCount); + expect(new Set(all.map((item) => item.id)).size).toBe(totalCount); + + // Like tidspunkt sorteres på id, også synkende. + const expectedOrder = [...all].sort( + (a, b) => + b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id), + ); + expect(all.map((item) => item.id)).toEqual( + expectedOrder.map((item) => item.id), + ); + }); + + it('sorterer annonser med samme tidspunkt på id, også over sidegrenser', async () => { + const first = await fetchPage(2, 0); + const second = await fetchPage(2, 2); + expect( + [...first.items, ...second.items].slice(0, 3).map((i) => i.id), + ).toEqual(['test-page-c', 'test-page-b', 'test-page-a']); + }); + + it('returnerer createdAt som ISO 8601-tekst', async () => { + const [item] = (await fetchPage(1, 0)).items; + expect(item).toBeDefined(); + expect(new Date(item!.createdAt).toISOString()).toBe(item!.createdAt); + }); + + it.each([ + { limit: 0 }, + { limit: -1 }, + { limit: MAX_PAGE_SIZE + 1 }, + { offset: -1 }, + ])('avviser ugyldig sidestørrelse eller posisjon %o', async (variables) => { + const result = await runListings(variables); + expect(result.data?.listings).toBeUndefined(); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); +}); From c1387e91e60b6da4ec4d80368dcc567d6e3b08b7 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Wed, 14 Oct 2026 21:35:29 +0200 Subject: [PATCH 6/6] =?UTF-8?q?docs(docs):=20dokumenter=20paginering=20i?= =?UTF-8?q?=20listings=20og=20m=C3=A5ling=20av=20indeks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: andretg --- README.md | 25 ++++++++++++++++++------- backend/deploy/README.md | 2 +- docs/datasett.md | 28 +++++++++++++++++++++++++++- docs/ki-bruk.md | 27 ++++++++++++++------------- docs/prosjektstandard.md | 19 ++++++++++--------- docs/testing.md | 40 +++++++++++++++++++++------------------- docs/vm-backend.md | 2 +- 7 files changed, 92 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 230acd8..3a55caf 100644 --- a/README.md +++ b/README.md @@ -246,11 +246,22 @@ Serveren logger URL til GraphQL-endepunktet (standard **Queries** -| Felt | Beskrivelse | -| ------------------ | --------------------------------------------- | -| `health` | Returnerer `"ok"`. | -| `listings` | Alle annonser, sortert på id. | -| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. | +| Felt | Beskrivelse | +| ----------------------------------- | -------------------------------------------------------- | +| `health` | Returnerer `"ok"`. | +| `listings(limit: Int, offset: Int)` | Én side med annonser, nyeste først. Se paginering under. | +| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. | + +**Paginering:** `listings` returnerer en `ListingPage` med `items`, +`totalCount` (antall annonser totalt) og `hasNextPage`. `limit` er +sidestørrelsen, standard 24 og maks 100. `offset` er hvor mange annonser som +hoppes over, standard 0. Ugyldige verdier gir `BAD_USER_INPUT`. En side utenfor +treffmengden gir tom `items`, men riktig `totalCount`. + +Annonsene sorteres på `createdAt` med nyeste først, og deretter på `id`. Den +andre nøkkelen gjør rekkefølgen stabil, slik at ingen annonser hoppes over +eller vises to ganger når man blar. Sorteringen har egen indeks, se +[docs/datasett.md](docs/datasett.md#paginering-og-indeks). **Mutations** @@ -264,12 +275,12 @@ med curl (juster URL om du har endret `PORT`): ```bash curl -s -X POST http://127.0.0.1:3001/graphql \ -H 'Content-Type: application/json' \ - -d '{"query":"{ health listings { id title dailyPrice } }"}' + -d '{"query":"{ health listings(limit: 5) { totalCount hasNextPage items { id title dailyPrice } } }"}' ``` ### API-tester (backend) -GraphQL-testene ligger i `backend/src/graphql/graphql.api.test.ts` og kjører +GraphQL-testene ligger i `backend/src/graphql/*.api.test.ts` og kjører mot en **egen testdatabase**, ikke utviklings- eller VM-databasen. 1. Kopier `backend/.env.test.example` til `backend/.env.test` og sett diff --git a/backend/deploy/README.md b/backend/deploy/README.md index 7e2a43b..55701c1 100644 --- a/backend/deploy/README.md +++ b/backend/deploy/README.md @@ -131,7 +131,7 @@ ss -tlnp | grep 3001 || true curl -sS -X POST http://127.0.0.1:3001/graphql \ -H 'Content-Type: application/json' \ - -d '{"query":"{ health listings { id } }"}' + -d '{"query":"{ health listings(limit: 1) { totalCount } }"}' ``` Stopp / restart: diff --git a/docs/datasett.md b/docs/datasett.md index e88de09..4f7013a 100644 --- a/docs/datasett.md +++ b/docs/datasett.md @@ -100,7 +100,7 @@ primærnøkkelen. Spørretidene er median av sju kjøringer med `EXPLAIN ANALYZE | `npm run seed:large` | 0,3 s | 1,2 s | | Søk på «drill» i tittel og beskrivelse, 24 treff sortert på pris | 14 ms | 68 ms | | Kategori og gratis, 24 nyeste | 0,9 ms | 4,0 ms | -| Alle annonser (dagens `listings`-spørring) | 21 ms | 150 ms | +| Alle annonser (`listings` før paginering) | 21 ms | 150 ms | Tidene vokser omtrent i takt med datamengden, fordi PostgreSQL må lese hele tabellen. Det viser hvorfor neste steg er serverbasert paginering og indekser, @@ -108,6 +108,32 @@ for eksempel på `category`, `daily_price` og `created_at`, og en trigram-indeks (`pg_trgm`) for søk i tekst. Med indekser bør tidene holde seg omtrent like uansett datamengde. +## Paginering og indeks + +`listings` henter én side om gangen, sortert på `created_at DESC, id DESC`. +Indeksen `listings_created_at_id_idx` har samme rekkefølge, så PostgreSQL kan +lese siden rett fra indeksen i stedet for å sortere hele tabellen. + +Målt 14. oktober 2026 lokalt med PostgreSQL 18, sider på 24 annonser. Tidene +er median av sju kjøringer med `EXPLAIN ANALYZE` etter én oppvarmingskjøring. + +| Spørring | 10 000 uten indeks | 10 000 med indeks | 50 000 med indeks | +| --------------------------- | ------------------ | ----------------- | ----------------- | +| Første side | 2,5 ms | 0,01 ms | 0,01 ms | +| Side midt i (offset 5 000) | 6,8 ms | 1,1 ms | 1,0 ms | +| Siste side | 11,4 ms | 3,3 ms | 15,2 ms | +| `COUNT(*)` for `totalCount` | 0,7 ms | 0,6 ms | 3,1 ms | + +Uten indeks leser PostgreSQL hele tabellen og sorterer den for hver side +(`Seq Scan` og `Sort`). Med indeks brukes `Index Scan`, og de første sidene +tar like kort tid uansett datamengde. + +**Begrensning:** Med `OFFSET` må PostgreSQL fortsatt gå gjennom alle radene +foran siden, så sider langt bak og `COUNT(*)` blir tregere når datamengden +vokser. Det merkes lite med 50 000 annonser. Blir det et problem, kan vi bytte +til cursor-paginering, der klienten sender med posisjonen til siste annonse på +forrige side. + ## Begrensninger - Annonsene er generert, så eiere, bookinger og vurderinger finnes ikke ennå. diff --git a/docs/ki-bruk.md b/docs/ki-bruk.md index 579faa2..06283d9 100644 --- a/docs/ki-bruk.md +++ b/docs/ki-bruk.md @@ -30,16 +30,17 @@ ser hva vi bør være oppmerksomme på. ## Logg -| Issue | Hva KI gjorde | Kontroll og funn | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| – | Gikk gjennom repoet mot arbeidsplanen og foreslo de resterende issuene til innlevering 1 | Issuene ble lest og opprettet av gruppen | -| #18 | Laget detaljvisning, `ListingImage`, `RentalRequestForm` og hjelpefunksjoner for leieperiode | Kontroller og nettleser på desktop og 360 px. Første forsøk på datolesing feilet typekontrollen og ble skrevet om | -| #19 | Laget utleieskjema, `SelectField`, `TextAreaField` og validering | Kontroller og nettleser. Fant at prismerket for betalt leie ble usynlig i forhåndsvisningen, og rettet det før commit | -| #20 | Skrev avsnittene om idé, visninger, mockup og planer i README | Teksten ble sjekket mot appen. Sortering ble utelatt til #16 var merget | -| #7 | Satte opp Vitest og skrev 79 tester, og kontrollerte ren installasjon, bredder og tastatur | En test sjekket påkrevde felt med `toBeInvalid()` og ville bestått uansett. Byttet til `aria-invalid`. Nettleserkontrollene var automatiserte, se [testing.md](testing.md) | -| #3 | Skrev prosjektstandard, maler for issues og pull requests og dette dokumentet | Standarden ble sjekket mot commit-historikken og reglene i repoet | -| #21 | Kjørte sluttkontrollen før innlevering 1 og rettet det den fant | Fant at `engines` tillot Node-versjoner som `jsdom` ikke støtter, og at favicon manglet. Begge er rettet. PR-er, review og merge til `main` gjøres av gruppen | -| #31 | Kjørte en kontroll på backend oppsettet | Sluttkontroll fant Prettier-feil og at seed-bilder ikke følger av Vite-build; dokumentasjon oppdatert. Migrate/seed og typecheck verifisert. PR og merge gjøres av gruppen | -| #33 | Kontroll på oppsettet av backend i VM | Maler er ikke aktivert på VM. GraphQL og Vite `base` gjenstår. SSH til VM ble kjørt manuelt. PR og merge gjøres av gruppen | -| #35 | Undersøkte eksisterende backend, utleieskjema og `listings`-tabell; foreslo og kontrollerte Apollo Server 5, GraphQL-schema, resolvere, PostgreSQL-repository med parameterisert SQL, servervalidering som frontend, og Vitest-integrasjonstester mot egen testdatabase | Typecheck og `npm test` i `backend/` mot lokal database med `_test`-suffiks. README, testing.md og deploy-maler oppdatert. Frontend uendret. PR og merge gjøres av gruppen | -| #39 | Søkte etter åpne datasett om verktøy på nettet (Wikidata, Open Products Facts, Geonorge) og foreslo Wikidata for verktøytyper og merker. Veiledet oppsett av lokal PostgreSQL-database. Skrev skript som henter verktøymodeller fra Wikidata (`import:tools`), migrasjon for `created_at`, generator for varierte annonser med tester og `seed:large` som setter inn datasettet | Wikidata-kategoriene ga mye støy, så verktøytypene velges med faste ID-er. Stikkprøver av genererte annonser viste tekster, tilbehør og merker som ikke passet verktøyet (for eksempel «båndsliper» som «passer til å skru opp hyller»), og dette ble rettet før commit. Migrasjon testet med up/down mot lokal database. | +| Issue | Hva KI gjorde | Kontroll og funn | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| – | Gikk gjennom repoet mot arbeidsplanen og foreslo de resterende issuene til innlevering 1 | Issuene ble lest og opprettet av gruppen | +| #18 | Laget detaljvisning, `ListingImage`, `RentalRequestForm` og hjelpefunksjoner for leieperiode | Kontroller og nettleser på desktop og 360 px. Første forsøk på datolesing feilet typekontrollen og ble skrevet om | +| #19 | Laget utleieskjema, `SelectField`, `TextAreaField` og validering | Kontroller og nettleser. Fant at prismerket for betalt leie ble usynlig i forhåndsvisningen, og rettet det før commit | +| #20 | Skrev avsnittene om idé, visninger, mockup og planer i README | Teksten ble sjekket mot appen. Sortering ble utelatt til #16 var merget | +| #7 | Satte opp Vitest og skrev 79 tester, og kontrollerte ren installasjon, bredder og tastatur | En test sjekket påkrevde felt med `toBeInvalid()` og ville bestått uansett. Byttet til `aria-invalid`. Nettleserkontrollene var automatiserte, se [testing.md](testing.md) | +| #3 | Skrev prosjektstandard, maler for issues og pull requests og dette dokumentet | Standarden ble sjekket mot commit-historikken og reglene i repoet | +| #21 | Kjørte sluttkontrollen før innlevering 1 og rettet det den fant | Fant at `engines` tillot Node-versjoner som `jsdom` ikke støtter, og at favicon manglet. Begge er rettet. PR-er, review og merge til `main` gjøres av gruppen | +| #31 | Kjørte en kontroll på backend oppsettet | Sluttkontroll fant Prettier-feil og at seed-bilder ikke følger av Vite-build; dokumentasjon oppdatert. Migrate/seed og typecheck verifisert. PR og merge gjøres av gruppen | +| #33 | Kontroll på oppsettet av backend i VM | Maler er ikke aktivert på VM. GraphQL og Vite `base` gjenstår. SSH til VM ble kjørt manuelt. PR og merge gjøres av gruppen | +| #35 | Undersøkte eksisterende backend, utleieskjema og `listings`-tabell; foreslo og kontrollerte Apollo Server 5, GraphQL-schema, resolvere, PostgreSQL-repository med parameterisert SQL, servervalidering som frontend, og Vitest-integrasjonstester mot egen testdatabase | Typecheck og `npm test` i `backend/` mot lokal database med `_test`-suffiks. README, testing.md og deploy-maler oppdatert. Frontend uendret. PR og merge gjøres av gruppen | +| #39 | Søkte etter åpne datasett om verktøy på nettet (Wikidata, Open Products Facts, Geonorge) og foreslo Wikidata for verktøytyper og merker. Veiledet oppsett av lokal PostgreSQL-database. Skrev skript som henter verktøymodeller fra Wikidata (`import:tools`), migrasjon for `created_at`, generator for varierte annonser med tester og `seed:large` som setter inn datasettet | Wikidata-kategoriene ga mye støy, så verktøytypene velges med faste ID-er. Stikkprøver av genererte annonser viste tekster, tilbehør og merker som ikke passet verktøyet (for eksempel «båndsliper» som «passer til å skru opp hyller»), og dette ble rettet før commit. Migrasjon testet med up/down mot lokal database. | +| #42 | Laget paginering i `listings` med validering, `createdAt` i skjemaet, indeks for sorteringen og API-tester, og målte spørringene med `EXPLAIN ANALYZE` | Kjørt mot ekte PostgreSQL 18 lokalt med 10 010 og 50 010 annonser. En test for rekkefølge ved like tidspunkt bestod også med feil sortering, fordi seed-annonsene har ulike tidspunkt. Testen ble skrevet om med egne testannonser og kontrollert ved å innføre feilen med vilje. Målingene viste at sider langt bak blir tregere med `OFFSET`, og det er dokumentert | diff --git a/docs/prosjektstandard.md b/docs/prosjektstandard.md index eae5bd6..c03bbcf 100644 --- a/docs/prosjektstandard.md +++ b/docs/prosjektstandard.md @@ -57,15 +57,16 @@ Beskrivelsen har liten forbokstav og ikke punktum, for eksempel | `build` | Avhengigheter, byggoppsett og verktøy | | `refactor` | Omskriving som ikke endrer oppførsel | -| Scope | Gjelder | -| -------- | ------------------------------------------- | -| `src` | Komponenter, hooks, utils og data i `src/` | -| `pages` | Sidene i `src/pages/` | -| `app` | Ruting og oppsett i `App.tsx` og `main.tsx` | -| `css` | Felles stiler i `src/styles/` | -| `config` | Konfigurasjonsfiler og `package.json` | -| `docs` | README og filene i `docs/` | -| `github` | Maler og innstillinger i `.github/` | +| Scope | Gjelder | +| --------- | ------------------------------------------- | +| `src` | Komponenter, hooks, utils og data i `src/` | +| `pages` | Sidene i `src/pages/` | +| `backend` | Backend, database og seed i `backend/` | +| `app` | Ruting og oppsett i `App.tsx` og `main.tsx` | +| `css` | Felles stiler i `src/styles/` | +| `config` | Konfigurasjonsfiler og `package.json` | +| `docs` | README og filene i `docs/` | +| `github` | Maler og innstillinger i `.github/` | Én commit skal inneholde én logisk endring. Når flere har jobbet sammen, legges de andre til med `Co-authored-by: navn ` nederst i meldingen. diff --git a/docs/testing.md b/docs/testing.md index 0891e39..5d17961 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -59,25 +59,27 @@ testdatabase-URL kjører fortsatt. ## Hva som testes -| Fil | Hva som testes | -| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `src/App.test.tsx` | Navigasjon via meny, annonsekort og tilbakelenke, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke, 404-sider, katalogsøk | -| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | -| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | -| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | -| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | -| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | -| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | -| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | -| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | -| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | -| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | -| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | -| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | - -Status 1. oktober 2026: 11 frontend-testfiler og 79 tester, alle grønne. Backend -GraphQL: 2 testfiler og 12 tester (integrasjon mot testdatabase når -`.env.test` er satt). +| Fil | Hva som testes | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.test.tsx` | Navigasjon via meny, annonsekort og tilbakelenke, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke, 404-sider, katalogsøk | +| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | +| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | +| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | +| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | +| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | +| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | +| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | +| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | +| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | +| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | +| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | +| `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | +| `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | +| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | + +Status 1. oktober 2026: 11 frontend-testfiler og 79 tester, alle grønne. +Status 14. oktober 2026, backend: 5 testfiler og 41 tester, alle grønne mot en +lokal testdatabase med PostgreSQL 18. Før merge av GraphQL-endringer: `cd backend && npm run typecheck && npm test`. diff --git a/docs/vm-backend.md b/docs/vm-backend.md index f07e0fa..288a727 100644 --- a/docs/vm-backend.md +++ b/docs/vm-backend.md @@ -357,7 +357,7 @@ curl -sS -X POST http://127.0.0.1:3001/graphql \ curl -sS -X POST http://127.0.0.1:3001/graphql \ -H 'Content-Type: application/json' \ - -d '{"query":"{ listings { id title dailyPrice } }"}' + -d '{"query":"{ listings(limit: 5) { totalCount items { id title dailyPrice } } }"}' ``` Test `createListing` og rydd opp (ingen delete-mutation — slett i SQL):