feat(API): Create schema and dto types for insights dashboard query param and api responses (#14163)

This commit is contained in:
Guillaume Jacquart
2025-03-25 12:37:58 +01:00
committed by GitHub
parent 1ff3049ffb
commit 6eee081cf3
5 changed files with 351 additions and 1 deletions

View File

@@ -59,3 +59,5 @@ export { CreateFolderDto } from './folders/create-folder.dto';
export { UpdateFolderDto } from './folders/update-folder.dto';
export { DeleteFolderDto } from './folders/delete-folder.dto';
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto';

View File

@@ -0,0 +1,94 @@
import { ListInsightsWorkflowQueryDto } from '../list-workflow-query.dto';
const DEFAULT_PAGINATION = { skip: 0, take: 10 };
describe('ListInsightsWorkflowQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'empty object (no filters)',
request: {},
parsedResult: DEFAULT_PAGINATION,
},
{
name: 'valid sortBy',
request: {
sortBy: 'total:asc',
},
parsedResult: {
...DEFAULT_PAGINATION,
sortBy: 'total:asc',
},
},
{
name: 'valid skip and take',
request: {
skip: '0',
take: '20',
},
parsedResult: {
skip: 0,
take: 20,
},
},
{
name: 'full query parameters',
request: {
skip: '0',
take: '10',
sortBy: 'total:desc',
},
parsedResult: {
skip: 0,
take: 10,
sortBy: 'total:desc',
},
},
])('should validate $name', ({ request, parsedResult }) => {
const result = ListInsightsWorkflowQueryDto.safeParse(request);
expect(result.success).toBe(true);
if (parsedResult) {
expect(result.data).toMatchObject(parsedResult);
}
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid skip format',
request: {
skip: 'not-a-number',
take: '10',
},
expectedErrorPath: ['skip'],
},
{
name: 'invalid take format',
request: {
skip: '0',
take: 'not-a-number',
},
expectedErrorPath: ['take'],
},
{
name: 'invalid sortBy value',
request: {
sortBy: 'invalid-value',
},
expectedErrorPath: ['sortBy'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ListInsightsWorkflowQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath && !result.success) {
if (Array.isArray(expectedErrorPath)) {
const errorPaths = result.error.issues[0].path;
expect(errorPaths).toContain(expectedErrorPath[0]);
}
}
});
});
});

View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
import { Z } from 'zod-class';
const VALID_SORT_OPTIONS = [
'total:asc',
'total:desc',
'succeeded:asc',
'succeeded:desc',
'failed:asc',
'failed:desc',
'timeSaved:asc',
'timeSaved:desc',
'runTime:asc',
'runTime:desc',
'averageRunTime:asc',
'averageRunTime:desc',
] as const;
// ---------------------
// Parameter Validators
// ---------------------
// Skip parameter validation
const skipValidator = z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 0))
.refine((val) => !isNaN(val), {
message: 'Skip must be a valid number',
});
// Take parameter validation
const takeValidator = z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 10))
.refine((val) => !isNaN(val), {
message: 'Take must be a valid number',
});
// SortBy parameter validation
const sortByValidator = z
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
.optional();
export class ListInsightsWorkflowQueryDto extends Z.class({
skip: skipValidator,
take: takeValidator,
sortBy: sortByValidator,
}) {}

View File

@@ -1,4 +1,8 @@
import { insightsSummarySchema } from '../insights.schema';
import {
insightsByTimeSchema,
insightsByWorkflowSchema,
insightsSummarySchema,
} from '../insights.schema';
describe('insightsSummarySchema', () => {
test.each([
@@ -62,3 +66,162 @@ describe('insightsSummarySchema', () => {
expect(result.success).toBe(expected);
});
});
describe('insightsByWorkflowSchema', () => {
test.each([
{
name: 'valid workflow insights',
value: {
count: 2,
data: [
{
workflowId: 'w1',
workflowName: 'Test Workflow',
projectId: 'p1',
projectName: 'Test Project',
total: 100,
succeeded: 90,
failed: 10,
failureRate: 0.56,
runTime: 300,
averageRunTime: 30.5,
timeSaved: 50,
},
],
},
expected: true,
},
{
name: 'wrong data type',
value: {
count: 2,
data: [
{
workflowId: 'w1',
total: '100',
succeeded: 90,
failed: 10,
failureRate: 10,
runTime: 300,
averageRunTime: 30,
timeSaved: 50,
},
],
},
expected: false,
},
{
name: 'missing required field',
value: {
count: 2,
data: [
{
workflowId: 'w1',
total: 100,
succeeded: 90,
failed: 10,
failureRate: 10,
runTime: 300,
averageRunTime: 30,
},
],
},
expected: false,
},
{
name: 'unexpected key',
value: {
count: 2,
data: [
{
workflowId: 'w1',
total: 100,
succeeded: 90,
failed: 10,
failureRate: 10,
runTime: 300,
averageRunTime: 30,
timeSaved: 50,
extraKey: 'value',
},
],
},
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = insightsByWorkflowSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('insightsByTimeSchema', () => {
test.each([
{
name: 'valid insights by time',
value: {
date: '2025-03-25T10:34:36.484Z',
values: {
total: 200,
failureRate: 10,
averageRunTime: 40,
timeSaved: 100,
},
},
expected: true,
},
{
name: 'invalid date format',
value: {
date: '20240325', // Should be a string
values: {
total: 200,
failureRate: 10,
averageRunTime: 40,
timeSaved: 100,
},
},
expected: false,
},
{
name: 'invalid field type',
value: {
date: 20240325, // Should be a string
values: {
total: 200,
failureRate: 10,
averageRunTime: 40,
timeSaved: 100,
},
},
expected: false,
},
{
name: 'missing required key',
value: {
date: '2025-03-25T10:34:36.484Z',
values: {
total: 200,
failureRate: 10,
averageRunTime: 40,
},
},
expected: false,
},
{
name: 'unexpected key',
value: {
date: '2025-03-25T10:34:36.484Z',
values: {
total: 200,
failureRate: 10,
averageRunTime: 40,
extraKey: 'value',
},
},
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = insightsByTimeSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});

View File

@@ -42,3 +42,44 @@ export const insightsSummaryDataSchemas = {
export const insightsSummarySchema = z.object(insightsSummaryDataSchemas).strict();
export type InsightsSummary = z.infer<typeof insightsSummarySchema>;
export const insightsByWorkflowDataSchemas = {
count: z.number(),
data: z.array(
z
.object({
workflowId: z.string(),
workflowName: z.string().optional(),
projectId: z.string().optional(),
projectName: z.string().optional(),
total: z.number(),
succeeded: z.number(),
failed: z.number(),
failureRate: z.number(),
runTime: z.number(),
averageRunTime: z.number(),
timeSaved: z.number(),
})
.strict(),
),
} as const;
export const insightsByWorkflowSchema = z.object(insightsByWorkflowDataSchemas).strict();
export type InsightsByWorkflow = z.infer<typeof insightsByWorkflowSchema>;
export const insightsByTimeDataSchemas = {
date: z.string().refine((val) => !isNaN(Date.parse(val)) && new Date(val).toISOString() === val, {
message: 'Invalid date format, must be ISO 8601 format',
}),
values: z
.object({
total: z.number(),
failureRate: z.number(),
averageRunTime: z.number(),
timeSaved: z.number(),
})
.strict(),
} as const;
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;