diff --git a/packages/api/src/index.test.ts b/packages/api/src/index.test.ts
new file mode 100644
index 0000000..1283c01
--- /dev/null
+++ b/packages/api/src/index.test.ts
@@ -0,0 +1,318 @@
+import { describe, it, expect } from "vitest";
+import {
+ API_VERSION,
+ ENDPOINTS,
+ // Errors
+ ApiErrorResponseSchema,
+ ERROR_CODES,
+ // Pagination
+ PaginationQuerySchema,
+ // Discovery
+ DiscoveryResponseSchema,
+ // Auth
+ TokenExchangeRequestSchema,
+ TokenResponseSchema,
+ DeviceSchema,
+ DeviceListResponseSchema,
+ // Routes
+ RouteSummarySchema,
+ RouteDetailSchema,
+ RouteListResponseSchema,
+ CreateRouteRequestSchema,
+ UpdateRouteRequestSchema,
+ ComputeRouteRequestSchema,
+ // Activities
+ ActivitySummarySchema,
+ ActivityDetailSchema,
+ ActivityListResponseSchema,
+ CreateActivityRequestSchema,
+ // Uploads
+ PresignedUploadRequestSchema,
+ PresignedUploadResponseSchema,
+} from "./index.ts";
+
+describe("API_VERSION", () => {
+ it("is a valid semver string", () => {
+ expect(API_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
+ });
+});
+
+describe("ENDPOINTS", () => {
+ it("has correct static paths", () => {
+ expect(ENDPOINTS.discovery).toBe("/.well-known/trails-cool");
+ expect(ENDPOINTS.routes.list).toBe("/api/v1/routes");
+ expect(ENDPOINTS.activities.list).toBe("/api/v1/activities");
+ expect(ENDPOINTS.auth.token).toBe("/api/v1/auth/token");
+ expect(ENDPOINTS.uploads.presign).toBe("/api/v1/uploads");
+ });
+
+ it("has correct dynamic paths", () => {
+ expect(ENDPOINTS.routes.detail("abc-123")).toBe("/api/v1/routes/abc-123");
+ expect(ENDPOINTS.activities.delete("xyz")).toBe("/api/v1/activities/xyz");
+ expect(ENDPOINTS.auth.device("dev-1")).toBe("/api/v1/auth/devices/dev-1");
+ });
+});
+
+describe("ApiErrorResponseSchema", () => {
+ it("accepts valid error", () => {
+ const result = ApiErrorResponseSchema.parse({
+ error: "Not found",
+ code: "NOT_FOUND",
+ });
+ expect(result.code).toBe("NOT_FOUND");
+ });
+
+ it("accepts error with field errors", () => {
+ const result = ApiErrorResponseSchema.parse({
+ error: "Validation failed",
+ code: ERROR_CODES.VALIDATION_ERROR,
+ fields: [{ field: "name", message: "Required" }],
+ });
+ expect(result.fields).toHaveLength(1);
+ });
+
+ it("rejects missing error field", () => {
+ expect(() => ApiErrorResponseSchema.parse({ code: "X" })).toThrow();
+ });
+});
+
+describe("PaginationQuerySchema", () => {
+ it("defaults limit to 20", () => {
+ const result = PaginationQuerySchema.parse({});
+ expect(result.limit).toBe(20);
+ expect(result.cursor).toBeUndefined();
+ });
+
+ it("coerces string limit", () => {
+ const result = PaginationQuerySchema.parse({ limit: "50" });
+ expect(result.limit).toBe(50);
+ });
+
+ it("rejects limit > 100", () => {
+ expect(() => PaginationQuerySchema.parse({ limit: 101 })).toThrow();
+ });
+});
+
+describe("DiscoveryResponseSchema", () => {
+ it("accepts valid discovery", () => {
+ const result = DiscoveryResponseSchema.parse({
+ apiVersion: "1.0.0",
+ instanceName: "trails.cool",
+ apiBaseUrl: "https://trails.cool/api/v1",
+ });
+ expect(result.apiVersion).toBe("1.0.0");
+ });
+
+ it("accepts optional tileUrl", () => {
+ const result = DiscoveryResponseSchema.parse({
+ apiVersion: "1.0.0",
+ instanceName: "my-instance",
+ apiBaseUrl: "https://example.com/api/v1",
+ tileUrl: "https://tiles.example.com/{z}/{x}/{y}.pbf",
+ });
+ expect(result.tileUrl).toBeDefined();
+ });
+});
+
+describe("TokenExchangeRequestSchema", () => {
+ it("accepts authorization_code grant", () => {
+ const result = TokenExchangeRequestSchema.parse({
+ grant_type: "authorization_code",
+ code: "abc123",
+ code_verifier: "verifier",
+ client_id: "trails-cool-mobile",
+ redirect_uri: "trailscool://auth/callback",
+ });
+ expect(result.grant_type).toBe("authorization_code");
+ });
+
+ it("accepts refresh_token grant", () => {
+ const result = TokenExchangeRequestSchema.parse({
+ grant_type: "refresh_token",
+ refresh_token: "refresh-abc",
+ client_id: "trails-cool-mobile",
+ });
+ expect(result.grant_type).toBe("refresh_token");
+ });
+
+ it("rejects invalid grant_type", () => {
+ expect(() => TokenExchangeRequestSchema.parse({
+ grant_type: "password",
+ client_id: "x",
+ })).toThrow();
+ });
+});
+
+describe("TokenResponseSchema", () => {
+ it("accepts valid token response", () => {
+ const result = TokenResponseSchema.parse({
+ access_token: "at-123",
+ refresh_token: "rt-456",
+ token_type: "Bearer",
+ expires_in: 3600,
+ });
+ expect(result.token_type).toBe("Bearer");
+ });
+});
+
+describe("CreateRouteRequestSchema", () => {
+ it("accepts minimal route", () => {
+ const result = CreateRouteRequestSchema.parse({ name: "My Route" });
+ expect(result.name).toBe("My Route");
+ expect(result.description).toBe("");
+ });
+
+ it("accepts route with GPX", () => {
+ const result = CreateRouteRequestSchema.parse({
+ name: "Berlin Tour",
+ description: "A nice ride",
+ gpx: "...",
+ routingProfile: "fastbike",
+ });
+ expect(result.gpx).toBeDefined();
+ });
+
+ it("rejects empty name", () => {
+ expect(() => CreateRouteRequestSchema.parse({ name: "" })).toThrow();
+ });
+
+ it("rejects name over 200 chars", () => {
+ expect(() => CreateRouteRequestSchema.parse({ name: "x".repeat(201) })).toThrow();
+ });
+});
+
+describe("UpdateRouteRequestSchema", () => {
+ it("accepts partial update", () => {
+ const result = UpdateRouteRequestSchema.parse({ name: "New Name" });
+ expect(result.name).toBe("New Name");
+ expect(result.gpx).toBeUndefined();
+ });
+
+ it("accepts GPX-only update", () => {
+ const result = UpdateRouteRequestSchema.parse({ gpx: "..." });
+ expect(result.gpx).toBeDefined();
+ });
+});
+
+describe("ComputeRouteRequestSchema", () => {
+ it("accepts valid waypoints", () => {
+ const result = ComputeRouteRequestSchema.parse({
+ waypoints: [
+ { lat: 52.52, lon: 13.405 },
+ { lat: 52.51, lon: 13.39 },
+ ],
+ });
+ expect(result.waypoints).toHaveLength(2);
+ expect(result.profile).toBe("fastbike");
+ });
+
+ it("rejects fewer than 2 waypoints", () => {
+ expect(() => ComputeRouteRequestSchema.parse({
+ waypoints: [{ lat: 52.52, lon: 13.405 }],
+ })).toThrow();
+ });
+});
+
+describe("RouteSummarySchema", () => {
+ const validRoute = {
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ name: "Berlin Tour",
+ description: "",
+ distance: 42000,
+ elevationGain: 340,
+ elevationLoss: 320,
+ routingProfile: "fastbike",
+ dayBreaks: [2],
+ geojson: null,
+ createdAt: "2026-04-12T10:00:00.000Z",
+ updatedAt: "2026-04-12T12:00:00.000Z",
+ };
+
+ it("accepts valid route summary", () => {
+ const result = RouteSummarySchema.parse(validRoute);
+ expect(result.name).toBe("Berlin Tour");
+ });
+
+ it("accepts nullable fields as null", () => {
+ const result = RouteSummarySchema.parse({
+ ...validRoute,
+ distance: null,
+ elevationGain: null,
+ elevationLoss: null,
+ routingProfile: null,
+ });
+ expect(result.distance).toBeNull();
+ });
+});
+
+describe("RouteListResponseSchema", () => {
+ it("accepts paginated response", () => {
+ const result = RouteListResponseSchema.parse({
+ routes: [],
+ nextCursor: "abc123",
+ });
+ expect(result.nextCursor).toBe("abc123");
+ });
+
+ it("accepts null nextCursor (last page)", () => {
+ const result = RouteListResponseSchema.parse({
+ routes: [],
+ nextCursor: null,
+ });
+ expect(result.nextCursor).toBeNull();
+ });
+});
+
+describe("CreateActivityRequestSchema", () => {
+ it("accepts minimal activity", () => {
+ const result = CreateActivityRequestSchema.parse({ name: "Morning Ride" });
+ expect(result.name).toBe("Morning Ride");
+ });
+
+ it("accepts activity with all fields", () => {
+ const result = CreateActivityRequestSchema.parse({
+ name: "Morning Ride",
+ description: "Nice weather",
+ gpx: "...",
+ routeId: "550e8400-e29b-41d4-a716-446655440000",
+ startedAt: "2026-04-12T07:00:00.000Z",
+ duration: 3600,
+ distance: 25000,
+ });
+ expect(result.routeId).toBeDefined();
+ });
+});
+
+describe("PresignedUploadRequestSchema", () => {
+ it("accepts valid upload request", () => {
+ const result = PresignedUploadRequestSchema.parse({
+ filename: "photo.jpg",
+ contentType: "image/jpeg",
+ resourceType: "route",
+ resourceId: "550e8400-e29b-41d4-a716-446655440000",
+ });
+ expect(result.resourceType).toBe("route");
+ });
+
+ it("rejects invalid resource type", () => {
+ expect(() => PresignedUploadRequestSchema.parse({
+ filename: "x.jpg",
+ contentType: "image/jpeg",
+ resourceType: "user",
+ resourceId: "550e8400-e29b-41d4-a716-446655440000",
+ })).toThrow();
+ });
+});
+
+describe("DeviceSchema", () => {
+ it("accepts valid device", () => {
+ const result = DeviceSchema.parse({
+ id: "dev-1",
+ deviceName: "iPhone 16e",
+ lastActiveAt: "2026-04-12T10:00:00.000Z",
+ createdAt: "2026-04-10T08:00:00.000Z",
+ isCurrent: true,
+ });
+ expect(result.deviceName).toBe("iPhone 16e");
+ });
+});