Added zod for validation

This commit is contained in:
Denis Urs Rudolph
2025-12-12 12:21:03 +01:00
parent 2b164d6525
commit 7f6db6d004
4 changed files with 160 additions and 36 deletions

View File

@@ -11,6 +11,7 @@
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.1.13",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

96
lib/api.test.ts Normal file
View File

@@ -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);
});
});
});

View File

@@ -1,54 +1,77 @@
import { z } from 'zod';
const API_URL = 'http://localhost:5000/api'; const API_URL = 'http://localhost:5000/api';
export interface Vehicle { // --- Zod Schemas ---
vin: string;
status: string;
currentVersion: string;
lastHeartbeat: string;
groupId?: number;
group?: VehicleGroup;
}
export interface VehicleGroup { // Base schemas to handle circular references if needed, though usually mapped by API
id: number; // defined separately to allow recursive type inference if we went down the z.lazy route,
name: string; // but for now we'll keep it simple as the API returns what it returns.
description?: string;
vehicles?: Vehicle[];
}
export interface FirmwareUpdate { export const BaseVehicleGroupSchema = z.object({
id: number; id: z.number(),
version: string; name: z.string(),
description?: string; description: z.string().optional().nullable(),
uploadedAt: string; });
}
export interface Deployment { export const VehicleSchema = z.object({
id: number; vin: z.string(),
updateId: number; status: z.string(),
targetVin?: string; currentVersion: z.string(),
targetGroupId?: number; lastHeartbeat: z.string(),
status: string; groupId: z.number().optional().nullable(),
createdAt: string; group: BaseVehicleGroupSchema.optional().nullable(),
update: FirmwareUpdate; });
}
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<typeof VehicleSchema>;
export type VehicleGroup = z.infer<typeof VehicleGroupSchema>;
export type FirmwareUpdate = z.infer<typeof FirmwareUpdateSchema>;
export type Deployment = z.infer<typeof DeploymentSchema>;
// --- API Functions with Validation ---
export async function fetchVehicles(): Promise<Vehicle[]> { export async function fetchVehicles(): Promise<Vehicle[]> {
const res = await fetch(`${API_URL}/admin/vehicles`); const res = await fetch(`${API_URL}/admin/vehicles`);
if (!res.ok) throw new Error('Failed to fetch 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<VehicleGroup[]> { export async function fetchGroups(): Promise<VehicleGroup[]> {
const res = await fetch(`${API_URL}/admin/groups`); const res = await fetch(`${API_URL}/admin/groups`);
if (!res.ok) throw new Error('Failed to fetch 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<FirmwareUpdate[]> { export async function getUpdates(): Promise<FirmwareUpdate[]> {
const res = await fetch(`${API_URL}/admin/updates`); const res = await fetch(`${API_URL}/admin/updates`);
if (!res.ok) throw new Error('Failed to fetch 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<VehicleGroup> { export async function createGroup(name: string, description?: string): Promise<VehicleGroup> {
@@ -58,7 +81,8 @@ export async function createGroup(name: string, description?: string): Promise<V
body: JSON.stringify({ name, description }), body: JSON.stringify({ name, description }),
}); });
if (!res.ok) throw new Error('Failed to create group'); if (!res.ok) throw new Error('Failed to create group');
return res.json(); const json = await res.json();
return VehicleGroupSchema.parse(json);
} }
export async function updateGroup(id: number, name: string, description?: string): Promise<VehicleGroup> { export async function updateGroup(id: number, name: string, description?: string): Promise<VehicleGroup> {
@@ -68,7 +92,8 @@ export async function updateGroup(id: number, name: string, description?: string
body: JSON.stringify({ name, description }), body: JSON.stringify({ name, description }),
}); });
if (!res.ok) throw new Error('Failed to update group'); 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<void> { export async function assignVehicleToGroup(groupId: number, vin: string): Promise<void> {
@@ -83,7 +108,8 @@ export async function assignVehicleToGroup(groupId: number, vin: string): Promis
export async function fetchDeployments(): Promise<Deployment[]> { export async function fetchDeployments(): Promise<Deployment[]> {
const res = await fetch(`${API_URL}/admin/deployments`); const res = await fetch(`${API_URL}/admin/deployments`);
if (!res.ok) throw new Error('Failed to fetch 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<void> { export async function uploadUpdate(formData: FormData): Promise<void> {

View File

@@ -14,7 +14,8 @@
"next": "16.0.8", "next": "16.0.8",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",