/** * Integration tests for GeoApiClient against a mock API server. * Covers the full flow from docs: register, login, collections, features. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { verifyAsync } from "@noble/ed25519"; import { base64UrlToBytes, bytesToBase64Url, textToBytes } from "../src/encoding"; import { GeoApiClient } from "../src/GeoApiClient"; import { generateKeyPair, signMessage } from "../src/keys"; class MemoryStorage { private data = new Map(); getItem(key: string): string | null { return this.data.get(key) ?? null; } setItem(key: string, value: string): void { this.data.set(key, value); } removeItem(key: string): void { this.data.delete(key); } } async function createMockServer(): Promise<{ url: string; server: ReturnType }> { const serverKeypair = await generateKeyPair(); const users = new Set(); const sessions = new Map(); let collectionId = 0; let featureId = 0; const server = Bun.serve({ port: 0, async fetch(req) { const url = new URL(req.url); const path = url.pathname; const method = req.method; // GET /v1/service-key if (method === "GET" && path === "/v1/service-key") { return Response.json({ publicKey: serverKeypair.publicKey }); } // POST /v1/auth/challenge if (method === "POST" && path === "/v1/auth/challenge") { const body = (await req.json()) as { publicKey?: string }; if (!body?.publicKey) { return Response.json({ error: "missing publicKey" }, { status: 400 }); } const nonce = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(24))); return Response.json({ nonce, messageToSign: `login:${nonce}` }); } // POST /v1/auth/register-by-signature if (method === "POST" && path === "/v1/auth/register-by-signature") { const body = (await req.json()) as { publicKey?: string; signature?: string }; if (!body?.publicKey || !body?.signature) { return Response.json({ error: "missing fields" }, { status: 400 }); } const ok = await verifyAsync( base64UrlToBytes(body.signature), textToBytes(serverKeypair.publicKey), base64UrlToBytes(body.publicKey) ); if (!ok) { return Response.json({ error: "invalid signature" }, { status: 400 }); } users.add(body.publicKey); return new Response(null, { status: 201 }); } // POST /v1/auth/login if (method === "POST" && path === "/v1/auth/login") { const body = (await req.json()) as { publicKey?: string; nonce?: string; signature?: string }; if (!body?.publicKey || !body?.nonce || !body?.signature) { return Response.json({ error: "missing fields" }, { status: 400 }); } const msg = `login:${body.nonce}`; const ok = await verifyAsync( base64UrlToBytes(body.signature), textToBytes(msg), base64UrlToBytes(body.publicKey) ); if (!ok) { return Response.json({ error: "invalid signature" }, { status: 401 }); } if (!users.has(body.publicKey)) { return Response.json({ error: "user not registered" }, { status: 401 }); } const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32))); sessions.set(token, body.publicKey); return Response.json({ accessToken: token }); } // Bearer auth helper const auth = req.headers.get("Authorization"); const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null; const user = token ? sessions.get(token) : null; if (!user && path.startsWith("/v1/collections") && method !== "GET") { return Response.json({ error: "unauthorized" }, { status: 401 }); } // POST /v1/collections if (method === "POST" && path === "/v1/collections") { const body = (await req.json()) as { name?: string }; collectionId++; const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" }; return Response.json(c, { status: 201 }); } // GET /v1/collections if (method === "GET" && path === "/v1/collections") { return Response.json({ collections: [] }); } // POST /v1/collections/:id/features if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) { const body = (await req.json()) as { geometry?: unknown; properties?: unknown }; featureId++; const f = { id: `feat-${featureId}`, geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] }, properties: body?.properties ?? {}, }; return Response.json(f, { status: 201 }); } // GET /v1/collections/:id/features if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) { return Response.json({ features: [] }); } return new Response("Not Found", { status: 404 }); }, }); const url = `http://localhost:${server.port}`; return { url, server }; } describe("GeoApiClient integration (docs flow)", () => { let mock: { url: string; server: ReturnType }; let client: GeoApiClient; let storage: MemoryStorage; beforeAll(async () => { mock = await createMockServer(); storage = new MemoryStorage(); client = new GeoApiClient(mock.url, storage); }); afterAll(() => { mock.server.stop(); }); test("getServicePublicKey fetches server public key", async () => { const { publicKey } = await client.getServicePublicKey(); expect(publicKey.length).toBeGreaterThan(10); }); test("full flow: ensureKeys -> register -> login -> createCollection -> createPointFeature -> listFeatures", async () => { const keys = await client.ensureKeysInStorage(); expect(keys.publicKey).toBeDefined(); expect(keys.privateKey).toBeDefined(); await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey); const token = await client.loginWithSignature(keys.publicKey, keys.privateKey); expect(token.length).toBeGreaterThan(10); client.setAccessToken(token); const created = await client.createCollection("My Places"); expect(created.id).toBeDefined(); expect(created.name).toBe("My Places"); const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Santa Cruz", }); expect(feature.id).toBeDefined(); const { features } = await client.listFeatures(created.id); expect(Array.isArray(features)).toBe(true); }); });