trails/apps/journal/app/routes/api.v1.test.ts
Ullrich Schäfer 234ab9f8c5
Add unit tests for REST API validation and pagination
Tests Zod schema validation for all API endpoints (routes, activities,
compute, uploads), error codes, and cursor pagination logic. 21 new
tests covering the validation layer without requiring a running DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:58:18 +02:00

173 lines
5.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
PaginationQuerySchema,
CreateRouteRequestSchema,
UpdateRouteRequestSchema,
CreateActivityRequestSchema,
ComputeRouteRequestSchema,
PresignedUploadRequestSchema,
ERROR_CODES,
} from "@trails-cool/api";
describe("Zod validation for API endpoints", () => {
describe("PaginationQuerySchema", () => {
it("defaults limit to 20", () => {
const result = PaginationQuerySchema.parse({});
expect(result.limit).toBe(20);
expect(result.cursor).toBeUndefined();
});
it("accepts valid cursor and limit", () => {
const result = PaginationQuerySchema.parse({ cursor: "abc", limit: "50" });
expect(result.cursor).toBe("abc");
expect(result.limit).toBe(50);
});
it("rejects limit > 100", () => {
expect(() => PaginationQuerySchema.parse({ limit: 101 })).toThrow();
});
});
describe("CreateRouteRequestSchema", () => {
it("accepts minimal route", () => {
const result = CreateRouteRequestSchema.parse({ name: "Test" });
expect(result.name).toBe("Test");
expect(result.description).toBe("");
});
it("rejects empty name", () => {
expect(() => CreateRouteRequestSchema.parse({ name: "" })).toThrow();
});
it("accepts route with GPX", () => {
const result = CreateRouteRequestSchema.parse({
name: "Tour",
gpx: "<gpx>...</gpx>",
routingProfile: "fastbike",
});
expect(result.gpx).toBeDefined();
});
});
describe("UpdateRouteRequestSchema", () => {
it("accepts partial update", () => {
const result = UpdateRouteRequestSchema.parse({ name: "New Name" });
expect(result.name).toBe("New Name");
});
it("accepts GPX-only update", () => {
const result = UpdateRouteRequestSchema.parse({ gpx: "<gpx/>" });
expect(result.gpx).toBe("<gpx/>");
});
});
describe("CreateActivityRequestSchema", () => {
it("accepts minimal activity", () => {
const result = CreateActivityRequestSchema.parse({ name: "Ride" });
expect(result.name).toBe("Ride");
});
it("accepts full activity", () => {
const result = CreateActivityRequestSchema.parse({
name: "Morning Ride",
gpx: "<gpx/>",
routeId: "550e8400-e29b-41d4-a716-446655440000",
startedAt: "2026-04-12T07:00:00.000Z",
duration: 3600,
distance: 25000,
});
expect(result.duration).toBe(3600);
});
});
describe("ComputeRouteRequestSchema", () => {
it("accepts valid waypoints", () => {
const result = ComputeRouteRequestSchema.parse({
waypoints: [
{ lat: 52.52, lon: 13.405 },
{ lat: 48.137, lon: 11.576 },
],
});
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("PresignedUploadRequestSchema", () => {
it("accepts valid upload", () => {
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: "abc",
})).toThrow();
});
});
});
describe("ERROR_CODES", () => {
it("has all expected codes", () => {
expect(ERROR_CODES.VALIDATION_ERROR).toBe("VALIDATION_ERROR");
expect(ERROR_CODES.NOT_FOUND).toBe("NOT_FOUND");
expect(ERROR_CODES.UNAUTHORIZED).toBe("UNAUTHORIZED");
expect(ERROR_CODES.INTERNAL_ERROR).toBe("INTERNAL_ERROR");
});
});
describe("cursor pagination logic", () => {
const items = [
{ id: "a" }, { id: "b" }, { id: "c" },
{ id: "d" }, { id: "e" }, { id: "f" },
];
function paginate(cursor: string | undefined, limit: number) {
let startIdx = 0;
if (cursor) {
const idx = items.findIndex((r) => r.id === cursor);
startIdx = idx >= 0 ? idx + 1 : 0;
}
const page = items.slice(startIdx, startIdx + limit);
const nextCursor = startIdx + limit < items.length
? page[page.length - 1]?.id ?? null
: null;
return { page, nextCursor };
}
it("returns first page without cursor", () => {
const { page, nextCursor } = paginate(undefined, 3);
expect(page.map((p) => p.id)).toEqual(["a", "b", "c"]);
expect(nextCursor).toBe("c");
});
it("returns next page with cursor", () => {
const { page, nextCursor } = paginate("c", 3);
expect(page.map((p) => p.id)).toEqual(["d", "e", "f"]);
expect(nextCursor).toBeNull();
});
it("returns null cursor on last page", () => {
const { nextCursor } = paginate("d", 10);
expect(nextCursor).toBeNull();
});
it("resets to start for unknown cursor", () => {
const { page } = paginate("unknown", 2);
expect(page.map((p) => p.id)).toEqual(["a", "b"]);
});
});