Add bun integration tests for docs flow, update integration guide
CI / test (push) Successful in 4s

- 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:
2026-03-01 13:08:09 +00:00
parent 18328706bd
commit a295e36bac
14 changed files with 654 additions and 14 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ This file gives future coding agents a fast path map for this repository.
- API entrypoint: `cmd/api/main.go` - API entrypoint: `cmd/api/main.go`
- HTTP routes/handlers: `internal/http/handlers.go` - HTTP routes/handlers: `internal/http/handlers.go`
- Core domain logic: `internal/app/service.go` - Core domain logic: `internal/app/service.go`
- In-memory persistence: `internal/store/` - Persistence: `internal/store/` — PostgreSQL (when `DATABASE_URL` set) or in-memory; migrations in `internal/store/migrations/`
- Auth utilities: `internal/auth/` - Auth utilities: `internal/auth/`
- Frontend static app: `web/` - Frontend static app: `web/`
- TypeScript API client: `libs/geo-api-client/` - TypeScript API client: `libs/geo-api-client/`
+1 -1
View File
@@ -52,7 +52,7 @@ Or use `./bin/up.sh` which runs the key generation if needed.
This starts: This starts:
- `db` (`postgis/postgis`) on `5432` - `db` (`postgis/postgis`) on `5432`
- `api` on `8122`, wired with `DATABASE_URL` to the `db` service - `api` on `8122` — uses PostgreSQL via `DATABASE_URL` (migrations run on startup)
Stop the service: Stop the service:
+16 -2
View File
@@ -27,8 +27,22 @@ func main() {
servicePublicKey = adminPublicKey servicePublicKey = adminPublicKey
} }
memory := store.NewMemoryStore() var st store.Store
service := app.NewService(memory, app.Config{ if databaseURL := os.Getenv("DATABASE_URL"); databaseURL != "" {
if err := store.Migrate(databaseURL); err != nil {
log.Fatalf("migrate: %v", err)
}
pg, err := store.NewPostgresStore(databaseURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
st = pg
} else {
st = store.NewMemoryStore()
}
service := app.NewService(st, app.Config{
ChallengeTTL: 5 * time.Minute, ChallengeTTL: 5 * time.Minute,
SessionTTL: 24 * time.Hour, SessionTTL: 24 * time.Hour,
}, servicePublicKey) }, servicePublicKey)
+10 -3
View File
@@ -25,10 +25,12 @@ Deployment: API is proxied via reverse proxy from `https://momswap.produktor.duc
```bash ```bash
cd libs/geo-api-client cd libs/geo-api-client
bun install bun install
bun test bun test # unit + integration tests (docs flow)
bun run build bun run build
``` ```
Integration tests in `test/integration.test.ts` cover the recommended flow: register, login, create collection, create point feature, list features.
## Public API (current) ## Public API (current)
### Class: `GeoApiClient` ### Class: `GeoApiClient`
@@ -80,7 +82,6 @@ Server keys are generated with `./bin/gen-server-keys.sh` and stored in `etc/`.
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js"; import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
const storage = window.localStorage; const storage = window.localStorage;
const storageLike = { const storageLike = {
getItem: (key: string) => storage.getItem(key), getItem: (key: string) => storage.getItem(key),
setItem: (key: string, value: string) => storage.setItem(key, value), setItem: (key: string, value: string) => storage.setItem(key, value),
@@ -88,8 +89,14 @@ const storageLike = {
}; };
const client = new GeoApiClient("https://momswap.produktor.duckdns.org", storageLike); const client = new GeoApiClient("https://momswap.produktor.duckdns.org", storageLike);
const keys = await client.ensureKeysInStorage(); const keys = await client.ensureKeysInStorage();
// Register (ignored if already registered); then login
try {
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
} catch (_) {
// Already registered or registration disabled
}
await client.loginWithSignature(keys.publicKey, keys.privateKey); await client.loginWithSignature(keys.publicKey, keys.privateKey);
const created = await client.createCollection("My Places"); const created = await client.createCollection("My Places");
+9
View File
@@ -1,3 +1,12 @@
module momswap/backend module momswap/backend
go 1.25 go 1.25
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+19
View File
@@ -0,0 +1,19 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -4
View File
@@ -15,7 +15,7 @@ var (
ErrUnauthorized = errors.New("unauthorized") ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
ErrBadRequest = errors.New("bad request") ErrBadRequest = errors.New("bad request")
ErrRegistrationNotConfigured = errors.New("registration by signature not configured; set ADMIN_PUBLIC_KEY") ErrRegistrationNotConfigured = errors.New("registration by signature not configured")
ErrInviteInvalid = errors.New("invite invalid") ErrInviteInvalid = errors.New("invite invalid")
ErrInviteExpired = errors.New("invite expired") ErrInviteExpired = errors.New("invite expired")
ErrInviteExhaust = errors.New("invite exhausted") ErrInviteExhaust = errors.New("invite exhausted")
@@ -30,13 +30,13 @@ type Config struct {
} }
type Service struct { type Service struct {
store *store.MemoryStore store store.Store
config Config config Config
servicePublicKey string servicePublicKey string
} }
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service { func NewService(st store.Store, cfg Config, servicePublicKey string) *Service {
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey} return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey}
} }
type InvitationPayload struct { type InvitationPayload struct {
+6 -1
View File
@@ -103,7 +103,12 @@ func statusFromErr(err error) int {
} }
func writeErr(w http.ResponseWriter, err error) { func writeErr(w http.ResponseWriter, err error) {
writeJSON(w, statusFromErr(err), map[string]string{"error": err.Error()}) payload := map[string]string{"error": err.Error()}
if errors.Is(err, app.ErrRegistrationNotConfigured) {
payload["code"] = "REGISTRATION_NOT_CONFIGURED"
payload["hint"] = "Run ./bin/gen-server-keys.sh to create etc/server-service.env, or set ADMIN_PUBLIC_KEY before starting the API."
}
writeJSON(w, statusFromErr(err), payload)
} }
func bearerToken(r *http.Request) (string, error) { func bearerToken(r *http.Request) (string, error) {
+24
View File
@@ -0,0 +1,24 @@
package store
import "time"
type Store interface {
UpsertUser(user User)
GetUser(publicKey string) (User, error)
CreateChallenge(ch Challenge) error
GetChallenge(nonce string) (Challenge, error)
MarkChallengeUsed(nonce string) error
SaveSession(session Session)
GetSession(token string) (Session, error)
SaveInvitation(inv Invitation) error
GetInvitation(jti string) (Invitation, error)
IncrementInvitationUsage(jti string) error
SaveCollection(c Collection)
ListCollectionsByOwner(owner string) []Collection
GetCollection(id string) (Collection, error)
SaveFeature(f Feature)
ListFeaturesByCollection(collectionID string) []Feature
GetFeature(featureID string) (Feature, error)
DeleteFeature(featureID string) error
PruneExpired(now time.Time)
}
+29
View File
@@ -0,0 +1,29 @@
package store
import (
"database/sql"
"embed"
"log"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations
var migrationsFS embed.FS
func Migrate(databaseURL string) error {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return err
}
defer db.Close()
sql, err := migrationsFS.ReadFile("migrations/0001_init.sql")
if err != nil {
return err
}
if _, err := db.Exec(string(sql)); err != nil {
return err
}
log.Printf("migrations applied")
return nil
}
+57
View File
@@ -0,0 +1,57 @@
-- Users
CREATE TABLE IF NOT EXISTS users (
public_key TEXT PRIMARY KEY,
inviter TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Challenges (login nonces)
CREATE TABLE IF NOT EXISTS challenges (
nonce TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE
);
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
-- Invitations
CREATE TABLE IF NOT EXISTS invitations (
jti TEXT PRIMARY KEY,
inviter_public_key TEXT NOT NULL,
invitee_public_key TEXT,
expires_at TIMESTAMPTZ NOT NULL,
max_uses INT NOT NULL,
used_count INT NOT NULL DEFAULT 0
);
-- Collections
CREATE TABLE IF NOT EXISTS collections (
id TEXT PRIMARY KEY,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Features (geometry and properties as JSONB)
CREATE TABLE IF NOT EXISTS features (
id TEXT PRIMARY KEY,
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
owner_key TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'Feature',
geometry JSONB NOT NULL,
properties JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_collections_owner ON collections(owner_key);
CREATE INDEX IF NOT EXISTS idx_features_collection ON features(collection_id);
CREATE INDEX IF NOT EXISTS idx_features_owner ON features(owner_key);
CREATE INDEX IF NOT EXISTS idx_challenges_expires ON challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
+292
View File
@@ -0,0 +1,292 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
type PostgresStore struct {
db *sql.DB
}
func NewPostgresStore(databaseURL string) (*PostgresStore, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return &PostgresStore{db: db}, nil
}
func (s *PostgresStore) Close() error {
return s.db.Close()
}
func (s *PostgresStore) UpsertUser(user User) {
_, _ = s.db.Exec(
`INSERT INTO users (public_key, inviter, created_at) VALUES ($1, $2, $3)
ON CONFLICT (public_key) DO UPDATE SET inviter = EXCLUDED.inviter`,
user.PublicKey, nullStr(user.Inviter), user.CreatedAt,
)
}
func (s *PostgresStore) GetUser(publicKey string) (User, error) {
var u User
err := s.db.QueryRow(
`SELECT public_key, COALESCE(inviter,''), created_at FROM users WHERE public_key = $1`,
publicKey,
).Scan(&u.PublicKey, &u.Inviter, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, err
}
return u, nil
}
func (s *PostgresStore) CreateChallenge(ch Challenge) error {
_, err := s.db.Exec(
`INSERT INTO challenges (nonce, public_key, expires_at, used) VALUES ($1, $2, $3, $4)`,
ch.Nonce, ch.PublicKey, ch.ExpiresAt, ch.Used,
)
if err != nil {
if isUniqueViolation(err) {
return ErrAlreadyExists
}
return err
}
return nil
}
func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) {
var ch Challenge
err := s.db.QueryRow(
`SELECT nonce, public_key, expires_at, used FROM challenges WHERE nonce = $1`,
nonce,
).Scan(&ch.Nonce, &ch.PublicKey, &ch.ExpiresAt, &ch.Used)
if errors.Is(err, sql.ErrNoRows) {
return Challenge{}, ErrNotFound
}
if err != nil {
return Challenge{}, err
}
return ch, nil
}
func (s *PostgresStore) MarkChallengeUsed(nonce string) error {
res, err := s.db.Exec(`UPDATE challenges SET used = true WHERE nonce = $1`, nonce)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) SaveSession(session Session) {
_, _ = s.db.Exec(
`INSERT INTO sessions (token, public_key, expires_at) VALUES ($1, $2, $3)
ON CONFLICT (token) DO UPDATE SET public_key = EXCLUDED.public_key, expires_at = EXCLUDED.expires_at`,
session.Token, session.PublicKey, session.ExpiresAt,
)
}
func (s *PostgresStore) GetSession(token string) (Session, error) {
var sess Session
err := s.db.QueryRow(
`SELECT token, public_key, expires_at FROM sessions WHERE token = $1`,
token,
).Scan(&sess.Token, &sess.PublicKey, &sess.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrNotFound
}
if err != nil {
return Session{}, err
}
return sess, nil
}
func (s *PostgresStore) SaveInvitation(inv Invitation) error {
_, err := s.db.Exec(
`INSERT INTO invitations (jti, inviter_public_key, invitee_public_key, expires_at, max_uses, used_count)
VALUES ($1, $2, $3, $4, $5, $6)`,
inv.JTI, inv.InviterPublicKey, nullStr(inv.InviteePublicKey), inv.ExpiresAt, inv.MaxUses, inv.UsedCount,
)
if err != nil {
if isUniqueViolation(err) {
return ErrAlreadyExists
}
return err
}
return nil
}
func (s *PostgresStore) GetInvitation(jti string) (Invitation, error) {
var inv Invitation
var invitee sql.NullString
err := s.db.QueryRow(
`SELECT jti, inviter_public_key, invitee_public_key, expires_at, max_uses, used_count
FROM invitations WHERE jti = $1`,
jti,
).Scan(&inv.JTI, &inv.InviterPublicKey, &invitee, &inv.ExpiresAt, &inv.MaxUses, &inv.UsedCount)
if errors.Is(err, sql.ErrNoRows) {
return Invitation{}, ErrNotFound
}
if err != nil {
return Invitation{}, err
}
inv.InviteePublicKey = invitee.String
return inv, nil
}
func (s *PostgresStore) IncrementInvitationUsage(jti string) error {
res, err := s.db.Exec(`UPDATE invitations SET used_count = used_count + 1 WHERE jti = $1`, jti)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) SaveCollection(c Collection) {
_, _ = s.db.Exec(
`INSERT INTO collections (id, owner_key, name, created_at) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
c.ID, c.OwnerKey, c.Name, c.CreatedAt,
)
}
func (s *PostgresStore) ListCollectionsByOwner(owner string) []Collection {
rows, err := s.db.Query(
`SELECT id, owner_key, name, created_at FROM collections WHERE owner_key = $1 ORDER BY created_at`,
owner,
)
if err != nil {
return nil
}
defer rows.Close()
var result []Collection
for rows.Next() {
var c Collection
if err := rows.Scan(&c.ID, &c.OwnerKey, &c.Name, &c.CreatedAt); err != nil {
return result
}
result = append(result, c)
}
return result
}
func (s *PostgresStore) GetCollection(id string) (Collection, error) {
var c Collection
err := s.db.QueryRow(
`SELECT id, owner_key, name, created_at FROM collections WHERE id = $1`,
id,
).Scan(&c.ID, &c.OwnerKey, &c.Name, &c.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Collection{}, ErrNotFound
}
if err != nil {
return Collection{}, err
}
return c, nil
}
func (s *PostgresStore) SaveFeature(f Feature) {
geom, _ := json.Marshal(f.Geometry)
props, _ := json.Marshal(f.Properties)
_, _ = s.db.Exec(
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET geometry = EXCLUDED.geometry, properties = EXCLUDED.properties, updated_at = EXCLUDED.updated_at`,
f.ID, f.CollectionID, f.OwnerKey, f.Type, geom, props, f.CreatedAt, f.UpdatedAt,
)
}
func (s *PostgresStore) ListFeaturesByCollection(collectionID string) []Feature {
rows, err := s.db.Query(
`SELECT id, collection_id, owner_key, type, geometry, properties, created_at, updated_at
FROM features WHERE collection_id = $1 ORDER BY created_at`,
collectionID,
)
if err != nil {
return nil
}
defer rows.Close()
var result []Feature
for rows.Next() {
var f Feature
var geom, props []byte
if err := rows.Scan(&f.ID, &f.CollectionID, &f.OwnerKey, &f.Type, &geom, &props, &f.CreatedAt, &f.UpdatedAt); err != nil {
return result
}
_ = json.Unmarshal(geom, &f.Geometry)
_ = json.Unmarshal(props, &f.Properties)
result = append(result, f)
}
return result
}
func (s *PostgresStore) GetFeature(featureID string) (Feature, error) {
var f Feature
var geom, props []byte
err := s.db.QueryRow(
`SELECT id, collection_id, owner_key, type, geometry, properties, created_at, updated_at
FROM features WHERE id = $1`,
featureID,
).Scan(&f.ID, &f.CollectionID, &f.OwnerKey, &f.Type, &geom, &props, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Feature{}, ErrNotFound
}
if err != nil {
return Feature{}, err
}
_ = json.Unmarshal(geom, &f.Geometry)
_ = json.Unmarshal(props, &f.Properties)
return f, nil
}
func (s *PostgresStore) DeleteFeature(featureID string) error {
res, err := s.db.Exec(`DELETE FROM features WHERE id = $1`, featureID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) PruneExpired(now time.Time) {
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
_, _ = s.db.Exec(`DELETE FROM invitations WHERE expires_at < $1`, now)
}
func nullStr(s string) interface{} {
if s == "" {
return nil
}
return s
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "duplicate key") || strings.Contains(s, "23505")
}
+3 -2
View File
@@ -52,8 +52,9 @@ export class GeoApiClient {
const body = init.body === undefined ? undefined : JSON.stringify(init.body); const body = init.body === undefined ? undefined : JSON.stringify(init.body);
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body }); const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
if (!res.ok) { if (!res.ok) {
const maybeJson = await res.json().catch(() => ({})); const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string; code?: string };
const msg = (maybeJson as { error?: string }).error ?? `HTTP ${res.status}`; let msg = maybeJson.error ?? `HTTP ${res.status}`;
if (maybeJson.hint) msg += `. ${maybeJson.hint}`;
throw new Error(msg); throw new Error(msg);
} }
if (res.status === 204) { if (res.status === 204) {
@@ -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);
});
});