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"); + }); +});