Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
pgm.dropIndex('listings', [], { name: 'listings_created_at_id_idx' });
}
44 changes: 38 additions & 6 deletions backend/src/db/listingsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,46 @@ const LISTING_COLUMNS = `
daily_price,
condition,
accessories,
image_url
image_url,
created_at
`;

export async function findAllListings(): Promise<Listing[]> {
const result = await pool.query<ListingRow>(
`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<ListingPage> {
const [itemsResult, countResult] = await Promise.all([
pool.query<ListingRow>(
`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<Listing | null> {
Expand Down
26 changes: 15 additions & 11 deletions backend/src/graphql/graphql.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}`,
});
Expand All @@ -90,8 +93,9 @@ describe('GraphQL API', () => {
}
expect(response.body.singleResult.errors).toBeUndefined();

const listings = response.body.singleResult.data?.listings as
Record<string, unknown>[] | undefined;
const page = response.body.singleResult.data?.listings as
{ items: Record<string, unknown>[] } | undefined;
const listings = page?.items;
expect(Array.isArray(listings)).toBe(true);
expect(listings!.length).toBeGreaterThan(0);

Expand Down
156 changes: 156 additions & 0 deletions backend/src/graphql/listingsPagination.api.test.ts
Original file line number Diff line number Diff line change
@@ -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<Page> {
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');
});
});
9 changes: 7 additions & 2 deletions backend/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
@@ -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),
},
Expand Down
16 changes: 15 additions & 1 deletion backend/src/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export const typeDefs = `#graphql
condition: ToolCondition!
accessories: [String!]!
imageUrl: String
"Når annonsen ble opprettet, som ISO 8601-tekst i UTC."
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 {
Expand All @@ -36,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
}
Expand Down
Loading