- Add test/integration.test.ts: getServicePublicKey, full flow (register, login, createCollection, createPointFeature, listFeatures) - Update docs example: registerBySigningServiceKey then loginWithSignature - Document integration tests in typescript-frontend-integration.md Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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<string, string>();
|
||||
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<typeof Bun.serve> }> {
|
||||
const serverKeypair = await generateKeyPair();
|
||||
const users = new Set<string>();
|
||||
const sessions = new Map<string, string>();
|
||||
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<typeof Bun.serve> };
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user