From 7f6db6d0041bbe5e99eac7f0ed3e0f8c205f3b4e Mon Sep 17 00:00:00 2001 From: Denis Urs Rudolph Date: Fri, 12 Dec 2025 12:21:03 +0100 Subject: [PATCH] Added zod for validation --- bun.lock | 1 + lib/api.test.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/api.ts | 96 +++++++++++++++++++++++++++++++------------------ package.json | 3 +- 4 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 lib/api.test.ts diff --git a/bun.lock b/bun.lock index cc284fb..823bc3a 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "react": "19.2.1", "react-dom": "19.2.1", "tailwind-merge": "^3.4.0", + "zod": "^4.1.13", }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/lib/api.test.ts b/lib/api.test.ts new file mode 100644 index 0000000..1aaf37a --- /dev/null +++ b/lib/api.test.ts @@ -0,0 +1,96 @@ +import { expect, test, describe } from "bun:test"; +import { VehicleSchema, VehicleGroupSchema, FirmwareUpdateSchema, DeploymentSchema } from "./api"; + +describe("Schema Validation", () => { + describe("VehicleSchema", () => { + test("should validate valid vehicle object", () => { + const validVehicle = { + vin: "TEST12345", + status: "Online", + currentVersion: "1.0.0", + lastHeartbeat: "2023-01-01T12:00:00Z", + groupId: 1, + group: { + id: 1, + name: "Test Group" + } + }; + const result = VehicleSchema.safeParse(validVehicle); + expect(result.success).toBe(true); + }); + + test("should fail on missing required fields", () => { + const invalidVehicle = { + vin: "TEST12345", + // status missing + currentVersion: "1.0.0" + }; + const result = VehicleSchema.safeParse(invalidVehicle); + expect(result.success).toBe(false); + }); + + test("should allow null/optional fields", () => { + const vehicleWithoutGroup = { + vin: "TEST_NO_GROUP", + status: "Offline", + currentVersion: "2.0", + lastHeartbeat: "2023-01-01T10:00:00Z", + groupId: null, + group: null + }; + const result = VehicleSchema.safeParse(vehicleWithoutGroup); + expect(result.success).toBe(true); + }); + }); + + describe("VehicleGroupSchema", () => { + test("should validate valid group", () => { + const validGroup = { + id: 10, + name: "Production", + description: "Main fleet", + vehicles: [] + }; + const result = VehicleGroupSchema.safeParse(validGroup); + expect(result.success).toBe(true); + }); + + test("should fail with invalid types", () => { + const invalidGroup = { + id: "should be number", + name: "Test" + }; + const result = VehicleGroupSchema.safeParse(invalidGroup); + expect(result.success).toBe(false); + }); + }); + + describe("FirmwareUpdateSchema", () => { + test("should validate update object", () => { + const update = { + id: 5, + version: "1.2.3", + uploadedAt: "2023-10-10", + description: "Security Patch" + }; + expect(FirmwareUpdateSchema.safeParse(update).success).toBe(true); + }); + }); + + describe("DeploymentSchema", () => { + test("should validate deployment", () => { + const deployment = { + id: 1, + updateId: 5, + status: "Pending", + createdAt: "2023-10-11", + update: { + id: 5, + version: "1.2.3", + uploadedAt: "2023-10-10" + } + }; + expect(DeploymentSchema.safeParse(deployment).success).toBe(true); + }); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index 6fa5950..6590838 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,54 +1,77 @@ +import { z } from 'zod'; + const API_URL = 'http://localhost:5000/api'; -export interface Vehicle { - vin: string; - status: string; - currentVersion: string; - lastHeartbeat: string; - groupId?: number; - group?: VehicleGroup; -} +// --- Zod Schemas --- -export interface VehicleGroup { - id: number; - name: string; - description?: string; - vehicles?: Vehicle[]; -} +// Base schemas to handle circular references if needed, though usually mapped by API +// defined separately to allow recursive type inference if we went down the z.lazy route, +// but for now we'll keep it simple as the API returns what it returns. -export interface FirmwareUpdate { - id: number; - version: string; - description?: string; - uploadedAt: string; -} +export const BaseVehicleGroupSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string().optional().nullable(), +}); -export interface Deployment { - id: number; - updateId: number; - targetVin?: string; - targetGroupId?: number; - status: string; - createdAt: string; - update: FirmwareUpdate; -} +export const VehicleSchema = z.object({ + vin: z.string(), + status: z.string(), + currentVersion: z.string(), + lastHeartbeat: z.string(), + groupId: z.number().optional().nullable(), + group: BaseVehicleGroupSchema.optional().nullable(), +}); + +export const VehicleGroupSchema = BaseVehicleGroupSchema.extend({ + vehicles: z.array(VehicleSchema).optional().nullable(), +}); + +export const FirmwareUpdateSchema = z.object({ + id: z.number(), + version: z.string(), + description: z.string().optional().nullable(), + uploadedAt: z.string(), +}); + +export const DeploymentSchema = z.object({ + id: z.number(), + updateId: z.number(), + targetVin: z.string().optional().nullable(), + targetGroupId: z.number().optional().nullable(), + status: z.string(), + createdAt: z.string(), + update: FirmwareUpdateSchema, +}); + +// --- Types Inferred from Schemas --- + +export type Vehicle = z.infer; +export type VehicleGroup = z.infer; +export type FirmwareUpdate = z.infer; +export type Deployment = z.infer; + +// --- API Functions with Validation --- export async function fetchVehicles(): Promise { const res = await fetch(`${API_URL}/admin/vehicles`); if (!res.ok) throw new Error('Failed to fetch vehicles'); - return res.json(); + const json = await res.json(); + return z.array(VehicleSchema).parse(json); } export async function fetchGroups(): Promise { const res = await fetch(`${API_URL}/admin/groups`); if (!res.ok) throw new Error('Failed to fetch groups'); - return res.json(); + const json = await res.json(); + return z.array(VehicleGroupSchema).parse(json); } export async function getUpdates(): Promise { const res = await fetch(`${API_URL}/admin/updates`); if (!res.ok) throw new Error('Failed to fetch updates'); - return res.json(); + const json = await res.json(); + return z.array(FirmwareUpdateSchema).parse(json); } export async function createGroup(name: string, description?: string): Promise { @@ -58,7 +81,8 @@ export async function createGroup(name: string, description?: string): Promise { @@ -68,7 +92,8 @@ export async function updateGroup(id: number, name: string, description?: string body: JSON.stringify({ name, description }), }); if (!res.ok) throw new Error('Failed to update group'); - return res.json(); + const json = await res.json(); + return VehicleGroupSchema.parse(json); } export async function assignVehicleToGroup(groupId: number, vin: string): Promise { @@ -83,7 +108,8 @@ export async function assignVehicleToGroup(groupId: number, vin: string): Promis export async function fetchDeployments(): Promise { const res = await fetch(`${API_URL}/admin/deployments`); if (!res.ok) throw new Error('Failed to fetch deployments'); - return res.json(); + const json = await res.json(); + return z.array(DeploymentSchema).parse(json); } export async function uploadUpdate(formData: FormData): Promise { diff --git a/package.json b/package.json index 377e144..fed3018 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "next": "16.0.8", "react": "19.2.1", "react-dom": "19.2.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "zod": "^4.1.13" }, "devDependencies": { "@tailwindcss/postcss": "^4",