CI / test (push) Successful in 5s
This updates developer docs and web demos to use backend upload endpoints, adds a client upload helper, and aligns integration tests with the no-direct-MinIO URL flow. Made-with: Cursor
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
/**
|
|
* 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;
|
|
let assetId = 0;
|
|
const collectionsByUser = new Map<string, Array<{ id: string; name: string }>>();
|
|
const featuresByCollection = new Map<string, Array<{ id: string; geometry: unknown; properties: Record<string, unknown> }>>();
|
|
const assetsByOwner = new Map<string, Array<{
|
|
id: string;
|
|
ownerKey: string;
|
|
checksum: string;
|
|
ext: string;
|
|
kind: "image" | "3d";
|
|
mimeType?: string;
|
|
sizeBytes: number;
|
|
objectKey: string;
|
|
isPublic: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}>>();
|
|
|
|
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<string, unknown>) ?? {},
|
|
};
|
|
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: `/v1/assets/${id}/upload`, method: "PUT" });
|
|
}
|
|
|
|
// PUT /v1/assets/:id/upload
|
|
if (method === "PUT" && path.match(/^\/v1\/assets\/[^/]+\/upload$/)) {
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
// 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<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);
|
|
});
|
|
|
|
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(`/v1/assets/${createdAsset.asset.id}/upload`);
|
|
|
|
await client.uploadAssetBinary(createdAsset.asset.id, new Blob(["fake-glb"]), "model/gltf-binary");
|
|
|
|
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);
|
|
});
|
|
});
|