/** * 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; let assetId = 0; const collectionsByUser = new Map>(); const featuresByCollection = new Map }>>(); const assetsByOwner = new Map>(); 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 }); } if (!user && path.startsWith("/v1/assets")) { 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" }; const list = collectionsByUser.get(user!) ?? []; list.push(c); collectionsByUser.set(user!, list); return Response.json(c, { status: 201 }); } // GET /v1/collections if (method === "GET" && path === "/v1/collections") { return Response.json({ collections: collectionsByUser.get(user ?? "") ?? [] }); } // POST /v1/collections/:id/features if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) { const colId = path.split("/")[3]!; const owned = (collectionsByUser.get(user!) ?? []).some((c) => c.id === colId); if (!owned) { return Response.json({ error: "forbidden" }, { status: 403 }); } 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 as Record) ?? {}, }; const list = featuresByCollection.get(colId) ?? []; list.push(f); featuresByCollection.set(colId, list); return Response.json(f, { status: 201 }); } // GET /v1/collections/:id/features if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) { const colId = path.split("/")[3]!; const features = featuresByCollection.get(colId) ?? []; const ownerAssets = assetsByOwner.get(user ?? "") ?? []; const withAssets = features.map((f) => { const assets = ownerAssets .filter((a) => (f.properties.assets as Array<{ id: string }> | undefined)?.some((x) => x.id === a.id)) .map((a) => ({ id: a.id, kind: a.kind, checksum: a.checksum, ext: a.ext, isPublic: a.isPublic, link: `/v1/assets/${a.id}/download`, })); return { ...f, properties: { ...f.properties, assets } }; }); return Response.json({ features: withAssets }); } // POST /v1/assets if (method === "POST" && path === "/v1/assets") { const body = (await req.json()) as { featureId?: string; checksum?: string; ext?: string; kind?: "image" | "3d"; mimeType?: string; sizeBytes?: number; name?: string; description?: string; isPublic?: boolean; }; if (!body.featureId || !body.checksum || !body.ext || !body.kind) { return Response.json({ error: "missing fields" }, { status: 400 }); } const ownerAssets = assetsByOwner.get(user!) ?? []; const existing = ownerAssets.find( (a) => a.checksum === body.checksum.toLowerCase() && a.ext === body.ext.toLowerCase() ); const now = new Date().toISOString(); const asset = existing ?? { id: `asset-${++assetId}`, ownerKey: user!, checksum: body.checksum.toLowerCase(), ext: body.ext.toLowerCase(), kind: body.kind, mimeType: body.mimeType, sizeBytes: body.sizeBytes ?? 0, objectKey: `${user!}/${body.checksum.toLowerCase()}.${body.ext.toLowerCase()}`, isPublic: body.isPublic ?? true, createdAt: now, updatedAt: now, }; if (!existing) { ownerAssets.push(asset); assetsByOwner.set(user!, ownerAssets); } for (const featureList of featuresByCollection.values()) { for (const f of featureList) { if (f.id === body.featureId) { const oldAssets = Array.isArray(f.properties.assets) ? (f.properties.assets as Array<{ id: string }>) : []; if (!oldAssets.some((x) => x.id === asset.id)) { f.properties.assets = [...oldAssets, { id: asset.id, name: body.name, description: body.description }]; } } } } return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }, { status: existing ? 200 : 201 }); } // POST /v1/assets/:id/signed-upload if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) { const id = path.split("/")[3]!; return Response.json({ url: `http://upload.local/${id}`, method: "PUT" }); } // PATCH /v1/assets/:id if (method === "PATCH" && path.match(/^\/v1\/assets\/[^/]+$/)) { const id = path.split("/")[3]!; const body = (await req.json()) as { isPublic?: boolean }; const ownerAssets = assetsByOwner.get(user!) ?? []; const asset = ownerAssets.find((a) => a.id === id); if (!asset) { return Response.json({ error: "not found" }, { status: 404 }); } asset.isPublic = Boolean(body.isPublic); asset.updatedAt = new Date().toISOString(); return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }); } 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); }); test("asset flow: create/link -> signed upload -> toggle visibility", async () => { const keys = await client.ensureKeysInStorage(); await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey); await client.loginWithSignature(keys.publicKey, keys.privateKey); const created = await client.createCollection("3D"); const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Palm Tree Point" }); const createdAsset = await client.createOrLinkAsset({ featureId: feature.id, checksum: "ABCDEF0123", ext: "glb", kind: "3d", mimeType: "model/gltf-binary", sizeBytes: 1024, name: "Palm Tree", description: "Low poly", isPublic: true, }); expect(createdAsset.asset.id).toBeDefined(); expect(createdAsset.link).toBe(`/v1/assets/${createdAsset.asset.id}/download`); expect(client.resolveRelativeLink(createdAsset.link)).toContain(`/v1/assets/${createdAsset.asset.id}/download`); const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary"); expect(upload.method).toBe("PUT"); expect(upload.url).toContain(createdAsset.asset.id); const toggled = await client.setAssetVisibility(createdAsset.asset.id, false); expect(toggled.asset.isPublic).toBe(false); const listed = await client.listFeatures(created.id); const first = listed.features[0]; const assets = first.properties?.assets ?? []; expect(assets.length).toBeGreaterThan(0); expect(assets[0]?.id).toBe(createdAsset.asset.id); }); });