diff --git a/src/test/listingMocks.ts b/src/test/listingMocks.ts
new file mode 100644
index 0000000..7d02586
--- /dev/null
+++ b/src/test/listingMocks.ts
@@ -0,0 +1,80 @@
+import type { MockLink } from '@apollo/client/testing';
+import { mockListings } from '../data/mockListings.ts';
+import {
+ CATALOG_PAGE_SIZE,
+ LISTINGS_PAGE_QUERY,
+ LISTING_QUERY,
+} from '../graphql/listingQueries.ts';
+import type { Listing } from '../types/listing.ts';
+
+/**
+ * Mockede GraphQL-svar for testene. Annonsene er de samme eksemplene som i
+ * prototypen, men komponentene får dem gjennom Apollo, slik de gjør mot
+ * backenden. Apollo legger til __typename i spørringene, så svarene må ha det.
+ */
+
+function toSummary(listing: Listing) {
+ const { id, title, category, area, dailyPrice, imageUrl } = listing;
+ return {
+ __typename: 'Listing' as const,
+ id,
+ title,
+ category,
+ area,
+ dailyPrice,
+ imageUrl,
+ };
+}
+
+interface PageMockOptions {
+ page?: number;
+ items?: Listing[];
+ totalCount?: number;
+}
+
+/** Svar på katalogspørringen for én side. */
+export function listingsPageMock({
+ page = 1,
+ items = mockListings,
+ totalCount = items.length,
+}: PageMockOptions = {}): MockLink.MockedResponse {
+ const offset = (page - 1) * CATALOG_PAGE_SIZE;
+ return {
+ request: {
+ query: LISTINGS_PAGE_QUERY,
+ variables: { limit: CATALOG_PAGE_SIZE, offset },
+ },
+ result: {
+ data: {
+ listings: {
+ __typename: 'ListingPage',
+ items: items.map(toSummary),
+ totalCount,
+ hasNextPage: offset + items.length < totalCount,
+ },
+ },
+ },
+ };
+}
+
+/** Svar på detaljspørringen. null betyr at annonsen ikke finnes. */
+export function listingMock(
+ id: string,
+ listing: Listing | null = mockListings.find((item) => item.id === id) ?? null,
+): MockLink.MockedResponse {
+ return {
+ request: { query: LISTING_QUERY, variables: { id } },
+ result: {
+ data: {
+ listing: listing && { __typename: 'Listing', ...listing },
+ },
+ },
+ };
+}
+
+/** Nettverksfeil, som når backenden ikke svarer. */
+export function networkErrorMock(
+ request: MockLink.MockedResponse['request'],
+): MockLink.MockedResponse {
+ return { request, error: new Error('Failed to fetch') };
+}
diff --git a/src/test/renderApp.tsx b/src/test/renderApp.tsx
new file mode 100644
index 0000000..ed441cf
--- /dev/null
+++ b/src/test/renderApp.tsx
@@ -0,0 +1,23 @@
+import type { MockLink } from '@apollo/client/testing';
+import { MockedProvider } from '@apollo/client/testing/react';
+import { render } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import App from '../App.tsx';
+import { listingsPageMock } from './listingMocks.ts';
+
+/**
+ * Rendrer hele appen på en gitt adresse med mockede GraphQL-svar. Uten egne
+ * mocks får katalogen første side med eksempelannonsene.
+ */
+export function renderApp(
+ route = '/',
+ mocks: MockLink.MockedResponse[] = [listingsPageMock()],
+) {
+ return render(
+
+
+
+
+ ,
+ );
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 36d26bd..9b8baff 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -7,3 +7,6 @@ import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
+
+// jsdom har ikke scrolling. Katalogen scroller til toppen ved bytte av side.
+window.scrollTo = () => {};
diff --git a/src/types/listing.ts b/src/types/listing.ts
index b8c5abb..b2ec889 100644
--- a/src/types/listing.ts
+++ b/src/types/listing.ts
@@ -26,3 +26,9 @@ export interface Listing {
/** Adresse til bilde av verktøyet, eller null når annonsen mangler bilde. */
imageUrl: string | null;
}
+
+/** Feltene annonsekortet i katalogen trenger. */
+export type ListingSummary = Pick<
+ Listing,
+ 'id' | 'title' | 'category' | 'area' | 'dailyPrice' | 'imageUrl'
+>;
diff --git a/src/utils/catalogPage.test.ts b/src/utils/catalogPage.test.ts
new file mode 100644
index 0000000..8f60b9c
--- /dev/null
+++ b/src/utils/catalogPage.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { catalogPageLink, countPages, readCatalogPage } from './catalogPage.ts';
+
+describe('readCatalogPage', () => {
+ it('leser siden fra URL-en', () => {
+ expect(readCatalogPage(new URLSearchParams('side=3'))).toBe(3);
+ });
+
+ it.each(['', 'side=0', 'side=-2', 'side=abc', 'side=1.5'])(
+ 'gir første side for «%s»',
+ (search) => {
+ expect(readCatalogPage(new URLSearchParams(search))).toBe(1);
+ },
+ );
+});
+
+describe('catalogPageLink', () => {
+ it('lar første side være uten parameter', () => {
+ expect(catalogPageLink(1)).toBe('/');
+ expect(catalogPageLink(2)).toBe('/?side=2');
+ });
+});
+
+describe('countPages', () => {
+ it('runder opp, og gir minst én side', () => {
+ expect(countPages(10010, 24)).toBe(418);
+ expect(countPages(24, 24)).toBe(1);
+ expect(countPages(0, 24)).toBe(1);
+ });
+});
diff --git a/src/utils/catalogPage.ts b/src/utils/catalogPage.ts
new file mode 100644
index 0000000..83d9fbd
--- /dev/null
+++ b/src/utils/catalogPage.ts
@@ -0,0 +1,25 @@
+/** Navnet på sideparameteren i URL-en, for eksempel «?side=3». */
+const PARAM_PAGE = 'side';
+
+/**
+ * Leser gjeldende side fra URL-en. Mangler eller ugyldige verdier, som «0»,
+ * «-2» eller «abc», gir første side.
+ */
+export function readCatalogPage(params: URLSearchParams): number {
+ const value = params.get(PARAM_PAGE);
+ if (value === null || !/^\d+$/.test(value)) {
+ return 1;
+ }
+ const page = Number(value);
+ return page >= 1 ? page : 1;
+}
+
+/** Lenke til en side i katalogen. Første side får ingen parameter. */
+export function catalogPageLink(page: number): string {
+ return page <= 1 ? '/' : `/?${PARAM_PAGE}=${page}`;
+}
+
+/** Antall sider, minst én, slik at en tom katalog også har «side 1 av 1». */
+export function countPages(totalCount: number, pageSize: number): number {
+ return Math.max(1, Math.ceil(totalCount / pageSize));
+}
diff --git a/vite.config.ts b/vite.config.ts
index 18f31aa..6f17882 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,8 +2,24 @@ import react from '@vitejs/plugin-react';
// defineConfig fra vitest/config godtar også test-innstillingene under.
import { defineConfig } from 'vitest/config';
+/** Adressen til GraphQL-backenden ved lokal utvikling (backend/.env). */
+const BACKEND_URL = 'http://localhost:3001';
+
export default defineConfig({
+ // Appen publiseres under /project2 på VM-en. Samme base lokalt gjør at
+ // lenker, bilder og API-kall bruker de samme stiene i utvikling og drift.
+ base: '/project2/',
plugins: [react()],
+ server: {
+ proxy: {
+ // På VM-en sender Apache /project2/api/ videre til backenden. Lokalt
+ // gjør Vite det samme, så frontend slipper å kjenne til portnummeret.
+ '/project2/api': {
+ target: BACKEND_URL,
+ rewrite: (path) => path.replace(/^\/project2\/api/, ''),
+ },
+ },
+ },
test: {
// jsdom gir et DOM i Node, slik at komponentene kan rendres uten nettleser.
environment: 'jsdom',