From 986a4b5b7b84253798f6a1e8599ea2dd1cbc6944 Mon Sep 17 00:00:00 2001 From: mshriver Date: Wed, 5 Nov 2025 16:39:05 +0100 Subject: [PATCH 1/5] Add api and model tests with jest --- jest.config.js | 27 ++ package.json | 4 + src/__tests__/test-utils.ts | 96 ++++++ src/apis/__tests__/ProjectApi.test.ts | 309 +++++++++++++++++++ src/apis/__tests__/ResultApi.test.ts | 429 ++++++++++++++++++++++++++ src/models/__tests__/Project.test.ts | 179 +++++++++++ src/models/__tests__/Result.test.ts | 219 +++++++++++++ src/models/__tests__/Run.test.ts | 228 ++++++++++++++ yarn.lock | 218 ++++++++++++- 9 files changed, 1695 insertions(+), 14 deletions(-) create mode 100644 jest.config.js create mode 100644 src/__tests__/test-utils.ts create mode 100644 src/apis/__tests__/ProjectApi.test.ts create mode 100644 src/apis/__tests__/ResultApi.test.ts create mode 100644 src/models/__tests__/Project.test.ts create mode 100644 src/models/__tests__/Result.test.ts create mode 100644 src/models/__tests__/Run.test.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..666c999 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,27 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/index.ts', + '!src/**/__tests__/**', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 15, + functions: 15, + lines: 25, + statements: 25, + }, + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + verbose: true, + testTimeout: 10000, +}; + diff --git a/package.json b/package.json index 89e2461..848e228 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "build": "tsc && tsc -p tsconfig.esm.json", "prepack": "yarn build", "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", "integration:build": "tsc -p tsconfig.integration.json", "integration:run": "node integration-test.js", "integration": "yarn build && yarn integration:build && yarn integration:run", @@ -45,11 +47,13 @@ "dependencies": {}, "devDependencies": { "@eslint/js": "^9.16.0", + "@types/jest": "^29.5.14", "@types/node": "^24.10.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "jest": "^30.2.0", "prettier": "^3.4.0", + "ts-jest": "^29.2.5", "typescript": "^5.7.0", "typescript-eslint": "^8.18.0" }, diff --git a/src/__tests__/test-utils.ts b/src/__tests__/test-utils.ts new file mode 100644 index 0000000..98ac759 --- /dev/null +++ b/src/__tests__/test-utils.ts @@ -0,0 +1,96 @@ +/** + * Test utilities for mocking and testing API calls + */ + +import { Configuration } from '../runtime'; + +/** + * Create a mock Configuration for testing + */ +export function createMockConfiguration(overrides?: Partial): Configuration { + return new Configuration({ + basePath: 'http://localhost/api', + ...overrides, + }); +} + +/** + * Create a mock fetch response + */ +export function createMockResponse( + data: T, + status = 200, + headers: Record = {} +): Response { + const response = { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: new Headers({ + 'Content-Type': 'application/json', + ...headers, + }), + json: async () => data, + text: async () => JSON.stringify(data), + blob: async () => new Blob([JSON.stringify(data)]), + arrayBuffer: async () => new ArrayBuffer(0), + formData: async () => new FormData(), + clone: function () { + return this; + }, + } as Response; + + return response; +} + +/** + * Create a mock fetch function that returns a specific response + */ +export function createMockFetch(data: T, status = 200): jest.Mock { + return jest.fn().mockResolvedValue(createMockResponse(data, status)); +} + +/** + * Create a mock fetch that can handle multiple responses + */ +export function createMockFetchSequence( + responses: Array<{ data: unknown; status?: number }> +): jest.Mock { + const mockFetch = jest.fn(); + responses.forEach((response) => { + mockFetch.mockResolvedValueOnce( + createMockResponse(response.data, response.status ?? 200) + ); + }); + return mockFetch; +} + +/** + * Setup global fetch mock + */ +export function setupFetchMock(): void { + global.fetch = jest.fn(); +} + +/** + * Restore fetch after tests + */ +export function restoreFetch(): void { + if (global.fetch && jest.isMockFunction(global.fetch)) { + (global.fetch as jest.Mock).mockRestore(); + } +} + +/** + * Helper to validate that an object matches a type structure + */ +export function validateObjectStructure( + obj: unknown, + expectedKeys: Array +): obj is T { + if (typeof obj !== 'object' || obj === null) { + return false; + } + return expectedKeys.every((key) => key in obj); +} + diff --git a/src/apis/__tests__/ProjectApi.test.ts b/src/apis/__tests__/ProjectApi.test.ts new file mode 100644 index 0000000..8646650 --- /dev/null +++ b/src/apis/__tests__/ProjectApi.test.ts @@ -0,0 +1,309 @@ +import { ProjectApi } from '../ProjectApi'; +import { Project, ProjectList } from '../../models'; +import { Configuration } from '../../runtime'; +import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; + +describe('ProjectApi', () => { + let api: ProjectApi; + let mockFetch: jest.Mock; + + beforeEach(() => { + setupFetchMock(); + const config = new Configuration({ + basePath: 'http://localhost/api', + }); + api = new ProjectApi(config); + }); + + afterEach(() => { + restoreFetch(); + }); + + describe('addProject', () => { + it('should create a new project', async () => { + const newProject: Project = { + name: 'test-project', + title: 'Test Project', + }; + + const responseProject: Project = { + id: 'project-123', + ...newProject, + }; + + mockFetch = createMockFetch(responseProject, 201); + global.fetch = mockFetch; + + const result = await api.addProject({ project: newProject }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/project', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + expect(result.id).toBe('project-123'); + expect(result.name).toBe('test-project'); + }); + + it('should send project data with correct JSON format (snake_case)', async () => { + const newProject: Project = { + name: 'test-project', + title: 'Test Project', + ownerId: 'user-123', + groupId: 'group-456', + }; + + mockFetch = createMockFetch({ id: 'project-123', ...newProject }); + global.fetch = mockFetch; + + await api.addProject({ project: newProject }); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + + expect(body).toHaveProperty('name', 'test-project'); + expect(body).toHaveProperty('owner_id', 'user-123'); + expect(body).toHaveProperty('group_id', 'group-456'); + }); + + it('should handle errors when creating a project', async () => { + mockFetch = createMockFetch({ error: 'Bad Request' }, 400); + global.fetch = mockFetch; + + await expect(api.addProject({ project: {} })).rejects.toThrow(); + }); + }); + + describe('getProject', () => { + it('should fetch a project by ID', async () => { + const project: Project = { + id: 'project-123', + name: 'test-project', + title: 'Test Project', + }; + + mockFetch = createMockFetch(project); + global.fetch = mockFetch; + + const result = await api.getProject({ id: 'project-123' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/project/project-123', + expect.objectContaining({ + method: 'GET', + }) + ); + expect(result).toEqual(project); + }); + + it('should handle 404 when project not found', async () => { + mockFetch = createMockFetch({ error: 'Not Found' }, 404); + global.fetch = mockFetch; + + await expect(api.getProject({ id: 'non-existent' })).rejects.toThrow(); + }); + + it('should require id parameter', async () => { + // TypeScript should catch this, but testing runtime behavior + await expect(api.getProject({ id: null as any })).rejects.toThrow(); + }); + }); + + describe('getProjectList', () => { + it('should fetch a list of projects', async () => { + const projectList: ProjectList = { + projects: [ + { id: 'project-1', name: 'proj1' }, + { id: 'project-2', name: 'proj2' }, + ], + pagination: { + page: 1, + pageSize: 20, + totalItems: 2, + totalPages: 1, + }, + }; + + mockFetch = createMockFetch(projectList); + global.fetch = mockFetch; + + const result = await api.getProjectList({}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.projects).toHaveLength(2); + expect(result.pagination?.totalItems).toBe(2); + }); + + it('should handle pagination parameters', async () => { + mockFetch = createMockFetch({ projects: [] }); + global.fetch = mockFetch; + + await api.getProjectList({ + page: 2, + pageSize: 50, + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('page=2'); + expect(url).toContain('pageSize=50'); + }); + + it('should handle filter parameters', async () => { + mockFetch = createMockFetch({ projects: [] }); + global.fetch = mockFetch; + + await api.getProjectList({ + filter: ['name=test', 'active=true'], + ownerId: 'user-123', + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('filter=name%3Dtest'); + expect(url).toContain('ownerId=user-123'); + }); + + it('should return empty list when no projects exist', async () => { + const emptyList: ProjectList = { + projects: [], + pagination: { + page: 1, + pageSize: 20, + totalItems: 0, + totalPages: 0, + }, + }; + + mockFetch = createMockFetch(emptyList); + global.fetch = mockFetch; + + const result = await api.getProjectList({}); + + expect(result.projects).toHaveLength(0); + expect(result.pagination?.totalItems).toBe(0); + }); + }); + + describe('updateProject', () => { + it('should update an existing project', async () => { + const updatedProject: Project = { + id: 'project-123', + name: 'updated-project', + title: 'Updated Project', + }; + + mockFetch = createMockFetch(updatedProject); + global.fetch = mockFetch; + + const result = await api.updateProject({ + id: 'project-123', + project: updatedProject, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/project/project-123', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + expect(result.name).toBe('updated-project'); + }); + + it('should handle partial updates', async () => { + const partialUpdate: Project = { + title: 'New Title Only', + }; + + mockFetch = createMockFetch({ + id: 'project-123', + name: 'existing-name', + title: 'New Title Only', + }); + global.fetch = mockFetch; + + const result = await api.updateProject({ + id: 'project-123', + project: partialUpdate, + }); + + expect(result.title).toBe('New Title Only'); + expect(result.name).toBe('existing-name'); + }); + + it('should require id parameter', async () => { + await expect( + api.updateProject({ + id: null as any, + project: {}, + }) + ).rejects.toThrow(); + }); + }); + + describe('getFilterParams', () => { + it('should fetch filterable parameters for a project', async () => { + const filterParams = ['env', 'component', 'result']; + + mockFetch = createMockFetch(filterParams); + global.fetch = mockFetch; + + const result = await api.getFilterParams({ id: 'project-123' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/project/filter-params/project-123', + expect.objectContaining({ + method: 'GET', + }) + ); + expect(result).toEqual(filterParams); + }); + + it('should return empty array when no filters exist', async () => { + mockFetch = createMockFetch([]); + global.fetch = mockFetch; + + const result = await api.getFilterParams({ id: 'project-123' }); + + expect(result).toEqual([]); + }); + }); + + describe('authentication', () => { + it('should include Bearer token when configured', async () => { + const configWithAuth = new Configuration({ + basePath: 'http://localhost/api', + accessToken: 'test-token-123', + }); + const authenticatedApi = new ProjectApi(configWithAuth); + + mockFetch = createMockFetch({ projects: [] }); + global.fetch = mockFetch; + + await authenticatedApi.getProjectList({}); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe('Bearer test-token-123'); + }); + + it('should work without authentication when not configured', async () => { + mockFetch = createMockFetch({ projects: [] }); + global.fetch = mockFetch; + + await api.getProjectList({}); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + }); +}); + diff --git a/src/apis/__tests__/ResultApi.test.ts b/src/apis/__tests__/ResultApi.test.ts new file mode 100644 index 0000000..d345cec --- /dev/null +++ b/src/apis/__tests__/ResultApi.test.ts @@ -0,0 +1,429 @@ +import { ResultApi } from '../ResultApi'; +import { Result, ResultList, ResultResultEnum } from '../../models'; +import { Configuration } from '../../runtime'; +import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; + +describe('ResultApi', () => { + let api: ResultApi; + let mockFetch: jest.Mock; + + beforeEach(() => { + setupFetchMock(); + const config = new Configuration({ + basePath: 'http://localhost/api', + }); + api = new ResultApi(config); + }); + + afterEach(() => { + restoreFetch(); + }); + + describe('addResult', () => { + it('should create a new test result', async () => { + const newResult: Result = { + testId: 'test-123', + result: ResultResultEnum.Passed, + duration: 5.5, + startTime: '2024-01-01T00:00:00Z', + }; + + const responseResult = { + id: 'result-456', + test_id: 'test-123', + result: 'passed', + duration: 5.5, + start_time: '2024-01-01T00:00:00Z', + }; + + mockFetch = createMockFetch(responseResult, 201); + global.fetch = mockFetch; + + const result = await api.addResult({ result: newResult }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/result', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + expect(result.id).toBe('result-456'); + expect(result.testId).toBe('test-123'); + }); + + it('should send result data with correct JSON format (snake_case)', async () => { + const newResult: Result = { + testId: 'test-123', + startTime: '2024-01-01T00:00:00Z', + runId: 'run-789', + projectId: 'project-101', + }; + + mockFetch = createMockFetch({ id: 'result-456', ...newResult }); + global.fetch = mockFetch; + + await api.addResult({ result: newResult }); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + + expect(body).toHaveProperty('test_id', 'test-123'); + expect(body).toHaveProperty('start_time', '2024-01-01T00:00:00Z'); + expect(body).toHaveProperty('run_id', 'run-789'); + expect(body).toHaveProperty('project_id', 'project-101'); + }); + + it('should handle all result statuses', async () => { + const statuses = [ + ResultResultEnum.Passed, + ResultResultEnum.Failed, + ResultResultEnum.Error, + ResultResultEnum.Skipped, + ]; + + for (const status of statuses) { + mockFetch = createMockFetch({ id: 'result-123', result: status }); + global.fetch = mockFetch; + + const result = await api.addResult({ + result: { result: status }, + }); + + expect(result.result).toBe(status); + } + }); + + it('should handle metadata and params objects', async () => { + const newResult: Result = { + testId: 'test-123', + metadata: { + browser: 'chrome', + version: '100', + }, + params: { + timeout: 30, + retries: 3, + }, + }; + + mockFetch = createMockFetch({ id: 'result-456', ...newResult }); + global.fetch = mockFetch; + + const result = await api.addResult({ result: newResult }); + + expect(result.metadata).toEqual(newResult.metadata); + expect(result.params).toEqual(newResult.params); + }); + }); + + describe('getResult', () => { + it('should fetch a result by ID', async () => { + const result = { + id: 'result-123', + test_id: 'test-456', + result: 'passed', + duration: 10.5, + }; + + mockFetch = createMockFetch(result); + global.fetch = mockFetch; + + const fetchedResult = await api.getResult({ id: 'result-123' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/result/result-123', + expect.objectContaining({ + method: 'GET', + }) + ); + expect(fetchedResult.id).toBe('result-123'); + expect(fetchedResult.testId).toBe('test-456'); + expect(fetchedResult.result).toBe('passed'); + expect(fetchedResult.duration).toBe(10.5); + }); + + it('should handle 404 when result not found', async () => { + mockFetch = createMockFetch({ error: 'Not Found' }, 404); + global.fetch = mockFetch; + + await expect(api.getResult({ id: 'non-existent' })).rejects.toThrow(); + }); + + it('should require id parameter', async () => { + await expect(api.getResult({ id: null as any })).rejects.toThrow(); + }); + }); + + describe('getResultList', () => { + it('should fetch a list of results', async () => { + const resultList: ResultList = { + results: [ + { + id: 'result-1', + testId: 'test-1', + result: ResultResultEnum.Passed, + }, + { + id: 'result-2', + testId: 'test-2', + result: ResultResultEnum.Failed, + }, + ], + pagination: { + page: 1, + pageSize: 25, + totalItems: 2, + totalPages: 1, + }, + }; + + mockFetch = createMockFetch(resultList); + global.fetch = mockFetch; + + const result = await api.getResultList({}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.results).toHaveLength(2); + expect(result.pagination?.totalItems).toBe(2); + }); + + it('should handle pagination parameters', async () => { + mockFetch = createMockFetch({ results: [] }); + global.fetch = mockFetch; + + await api.getResultList({ + page: 3, + pageSize: 100, + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('page=3'); + expect(url).toContain('pageSize=100'); + }); + + it('should handle filter parameters', async () => { + mockFetch = createMockFetch({ results: [] }); + global.fetch = mockFetch; + + await api.getResultList({ + filter: ['result=passed', 'duration>10'], + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('filter=result%3Dpassed'); + expect(url).toContain('filter=duration'); + }); + + it('should handle estimate parameter', async () => { + mockFetch = createMockFetch({ + results: [], + pagination: { totalItems: '~1000' }, + }); + global.fetch = mockFetch; + + await api.getResultList({ + estimate: true, + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('estimate=true'); + }); + + it('should return empty list when no results exist', async () => { + const emptyList: ResultList = { + results: [], + pagination: { + page: 1, + pageSize: 25, + totalItems: 0, + totalPages: 0, + }, + }; + + mockFetch = createMockFetch(emptyList); + global.fetch = mockFetch; + + const result = await api.getResultList({}); + + expect(result.results).toHaveLength(0); + expect(result.pagination?.totalItems).toBe(0); + }); + }); + + describe('updateResult', () => { + it('should update an existing result', async () => { + const updatedResult: Result = { + id: 'result-123', + testId: 'test-456', + result: ResultResultEnum.Failed, + duration: 15.5, + }; + + mockFetch = createMockFetch(updatedResult); + global.fetch = mockFetch; + + const result = await api.updateResult({ + id: 'result-123', + result: updatedResult, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/result/result-123', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + expect(result.result).toBe(ResultResultEnum.Failed); + expect(result.duration).toBe(15.5); + }); + + it('should handle partial updates', async () => { + const partialUpdate: Result = { + result: ResultResultEnum.Error, + }; + + mockFetch = createMockFetch({ + id: 'result-123', + test_id: 'test-456', + result: 'error', + duration: 10, + }); + global.fetch = mockFetch; + + const result = await api.updateResult({ + id: 'result-123', + result: partialUpdate, + }); + + expect(result.result).toBe(ResultResultEnum.Error); + expect(result.testId).toBe('test-456'); + }); + + it('should handle metadata updates', async () => { + const updatedMetadata = { + metadata: { + retried: true, + failureReason: 'timeout', + }, + }; + + mockFetch = createMockFetch({ + id: 'result-123', + ...updatedMetadata, + }); + global.fetch = mockFetch; + + const result = await api.updateResult({ + id: 'result-123', + result: updatedMetadata, + }); + + expect(result.metadata).toEqual(updatedMetadata.metadata); + }); + + it('should require id parameter', async () => { + await expect( + api.updateResult({ + id: null as any, + result: {}, + }) + ).rejects.toThrow(); + }); + }); + + describe('filter operations', () => { + it('should handle complex filter queries', async () => { + mockFetch = createMockFetch({ results: [] }); + global.fetch = mockFetch; + + await api.getResultList({ + filter: [ + 'result=passed', + 'duration>5', + 'env=production', + 'metadata.browser~chrome', + ], + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('filter='); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('should handle special characters in filters', async () => { + mockFetch = createMockFetch({ results: [] }); + global.fetch = mockFetch; + + await api.getResultList({ + filter: ['test_id~test-.*-regex'], + }); + + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + describe('authentication', () => { + it('should include Bearer token when configured', async () => { + const configWithAuth = new Configuration({ + basePath: 'http://localhost/api', + accessToken: 'test-token-456', + }); + const authenticatedApi = new ResultApi(configWithAuth); + + mockFetch = createMockFetch({ results: [] }); + global.fetch = mockFetch; + + await authenticatedApi.getResultList({}); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe('Bearer test-token-456'); + }); + + it('should work without authentication when not configured', async () => { + mockFetch = createMockFetch({ results: [] }); + global.fetch = mockFetch; + + await api.getResultList({}); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle server errors', async () => { + mockFetch = createMockFetch({ error: 'Internal Server Error' }, 500); + global.fetch = mockFetch; + + await expect(api.getResultList({})).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = mockFetch; + + await expect(api.getResultList({})).rejects.toThrow(); + }); + + it('should handle malformed responses', async () => { + mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(new Error('Invalid JSON')), + }); + global.fetch = mockFetch; + + await expect(api.getResultList({})).rejects.toThrow(); + }); + }); +}); + diff --git a/src/models/__tests__/Project.test.ts b/src/models/__tests__/Project.test.ts new file mode 100644 index 0000000..465f2a7 --- /dev/null +++ b/src/models/__tests__/Project.test.ts @@ -0,0 +1,179 @@ +import { + Project, + ProjectFromJSON, + ProjectToJSON, + instanceOfProject, +} from '../Project'; + +describe('Project Model', () => { + describe('interface and types', () => { + it('should create a valid Project object with all fields', () => { + const project: Project = { + id: 'project-123', + name: 'my-project', + title: 'My Project', + ownerId: 'user-456', + groupId: 'group-789', + }; + + expect(project.id).toBe('project-123'); + expect(project.name).toBe('my-project'); + expect(project.title).toBe('My Project'); + expect(project.ownerId).toBe('user-456'); + expect(project.groupId).toBe('group-789'); + }); + + it('should create a Project object with minimal fields', () => { + const project: Project = {}; + expect(project).toBeDefined(); + }); + + it('should allow null values for nullable fields', () => { + const project: Project = { + id: 'project-123', + name: 'my-project', + ownerId: null, + groupId: null, + }; + + expect(project.ownerId).toBeNull(); + expect(project.groupId).toBeNull(); + }); + }); + + describe('ProjectFromJSON', () => { + it('should convert JSON with snake_case to Project object with camelCase', () => { + const json = { + id: 'project-123', + name: 'my-project', + title: 'My Project', + owner_id: 'user-456', + group_id: 'group-789', + }; + + const project = ProjectFromJSON(json); + + expect(project.id).toBe('project-123'); + expect(project.name).toBe('my-project'); + expect(project.title).toBe('My Project'); + expect(project.ownerId).toBe('user-456'); + expect(project.groupId).toBe('group-789'); + }); + + it('should handle null values correctly', () => { + const json = { + id: 'project-123', + name: 'my-project', + owner_id: null, + group_id: null, + }; + + const project = ProjectFromJSON(json); + + expect(project.id).toBe('project-123'); + expect(project.name).toBe('my-project'); + expect(project.ownerId).toBeUndefined(); + expect(project.groupId).toBeUndefined(); + }); + + it('should handle missing fields as undefined', () => { + const json = { + name: 'my-project', + }; + + const project = ProjectFromJSON(json); + + expect(project.name).toBe('my-project'); + expect(project.id).toBeUndefined(); + expect(project.title).toBeUndefined(); + }); + + it('should return null when passed null', () => { + const project = ProjectFromJSON(null); + expect(project).toBeNull(); + }); + }); + + describe('ProjectToJSON', () => { + it('should convert Project object with camelCase to JSON with snake_case', () => { + const project: Project = { + id: 'project-123', + name: 'my-project', + title: 'My Project', + ownerId: 'user-456', + groupId: 'group-789', + }; + + const json = ProjectToJSON(project); + + expect(json.id).toBe('project-123'); + expect(json.name).toBe('my-project'); + expect(json.title).toBe('My Project'); + expect(json.owner_id).toBe('user-456'); + expect(json.group_id).toBe('group-789'); + }); + + it('should handle undefined fields', () => { + const project: Project = { + name: 'my-project', + }; + + const json = ProjectToJSON(project); + + expect(json.name).toBe('my-project'); + expect(json.id).toBeUndefined(); + }); + + it('should return null when passed null', () => { + const json = ProjectToJSON(null); + expect(json).toBeNull(); + }); + + it('should return undefined when passed undefined', () => { + const json = ProjectToJSON(undefined); + expect(json).toBeUndefined(); + }); + }); + + describe('instanceOfProject', () => { + it('should return true for any object (as per implementation)', () => { + expect(instanceOfProject({})).toBe(true); + expect(instanceOfProject({ name: 'test' })).toBe(true); + expect( + instanceOfProject({ + id: 'project-123', + name: 'my-project', + }) + ).toBe(true); + }); + }); + + describe('JSON round-trip', () => { + it('should maintain data integrity through JSON conversion round-trip', () => { + const original: Project = { + id: 'project-123', + name: 'test-project', + title: 'Test Project', + ownerId: 'user-456', + groupId: 'group-789', + }; + + const json = ProjectToJSON(original); + const restored = ProjectFromJSON(json); + + expect(restored).toEqual(original); + }); + + it('should handle partial data through round-trip', () => { + const original: Project = { + name: 'minimal-project', + }; + + const json = ProjectToJSON(original); + const restored = ProjectFromJSON(json); + + expect(restored.name).toBe(original.name); + }); + }); +}); + diff --git a/src/models/__tests__/Result.test.ts b/src/models/__tests__/Result.test.ts new file mode 100644 index 0000000..d6add99 --- /dev/null +++ b/src/models/__tests__/Result.test.ts @@ -0,0 +1,219 @@ +import { + Result, + ResultResultEnum, + ResultFromJSON, + ResultToJSON, + instanceOfResult, +} from '../Result'; + +describe('Result Model', () => { + describe('interface and types', () => { + it('should create a valid Result object with all fields', () => { + const result: Result = { + id: 'result-123', + testId: 'test-456', + startTime: '2024-01-01T00:00:00Z', + duration: 10.5, + result: ResultResultEnum.Passed, + component: 'frontend', + env: 'production', + runId: 'run-789', + projectId: 'project-101', + metadata: { browser: 'chrome' }, + params: { timeout: 30 }, + source: 'pytest', + }; + + expect(result.id).toBe('result-123'); + expect(result.testId).toBe('test-456'); + expect(result.duration).toBe(10.5); + expect(result.result).toBe('passed'); + }); + + it('should create a Result object with minimal fields', () => { + const result: Result = {}; + expect(result).toBeDefined(); + }); + + it('should allow null values for nullable fields', () => { + const result: Result = { + component: null, + env: null, + runId: null, + projectId: null, + }; + + expect(result.component).toBeNull(); + expect(result.env).toBeNull(); + expect(result.runId).toBeNull(); + expect(result.projectId).toBeNull(); + }); + }); + + describe('ResultResultEnum', () => { + it('should have all expected result statuses', () => { + expect(ResultResultEnum.Passed).toBe('passed'); + expect(ResultResultEnum.Failed).toBe('failed'); + expect(ResultResultEnum.Error).toBe('error'); + expect(ResultResultEnum.Skipped).toBe('skipped'); + expect(ResultResultEnum.Xpassed).toBe('xpassed'); + expect(ResultResultEnum.Xfailed).toBe('xfailed'); + expect(ResultResultEnum.Manual).toBe('manual'); + expect(ResultResultEnum.Blocked).toBe('blocked'); + }); + + it('should use enum values in Result objects', () => { + const passed: Result = { result: ResultResultEnum.Passed }; + const failed: Result = { result: ResultResultEnum.Failed }; + const error: Result = { result: ResultResultEnum.Error }; + + expect(passed.result).toBe('passed'); + expect(failed.result).toBe('failed'); + expect(error.result).toBe('error'); + }); + }); + + describe('ResultFromJSON', () => { + it('should convert JSON with snake_case to Result object with camelCase', () => { + const json = { + id: 'result-123', + test_id: 'test-456', + start_time: '2024-01-01T00:00:00Z', + duration: 10.5, + result: 'passed', + component: 'frontend', + env: 'production', + run_id: 'run-789', + project_id: 'project-101', + metadata: { browser: 'chrome' }, + params: { timeout: 30 }, + source: 'pytest', + }; + + const result = ResultFromJSON(json); + + expect(result.id).toBe('result-123'); + expect(result.testId).toBe('test-456'); + expect(result.startTime).toBe('2024-01-01T00:00:00Z'); + expect(result.duration).toBe(10.5); + expect(result.result).toBe('passed'); + expect(result.runId).toBe('run-789'); + expect(result.projectId).toBe('project-101'); + }); + + it('should handle null values correctly', () => { + const json = { + id: 'result-123', + component: null, + env: null, + }; + + const result = ResultFromJSON(json); + + expect(result.id).toBe('result-123'); + expect(result.component).toBeUndefined(); + expect(result.env).toBeUndefined(); + }); + + it('should handle missing fields as undefined', () => { + const json = { + id: 'result-123', + }; + + const result = ResultFromJSON(json); + + expect(result.id).toBe('result-123'); + expect(result.testId).toBeUndefined(); + expect(result.duration).toBeUndefined(); + }); + + it('should return null when passed null', () => { + const result = ResultFromJSON(null); + expect(result).toBeNull(); + }); + }); + + describe('ResultToJSON', () => { + it('should convert Result object with camelCase to JSON with snake_case', () => { + const result: Result = { + id: 'result-123', + testId: 'test-456', + startTime: '2024-01-01T00:00:00Z', + duration: 10.5, + result: ResultResultEnum.Passed, + runId: 'run-789', + projectId: 'project-101', + metadata: { browser: 'chrome' }, + }; + + const json = ResultToJSON(result); + + expect(json.id).toBe('result-123'); + expect(json.test_id).toBe('test-456'); + expect(json.start_time).toBe('2024-01-01T00:00:00Z'); + expect(json.duration).toBe(10.5); + expect(json.result).toBe('passed'); + expect(json.run_id).toBe('run-789'); + expect(json.project_id).toBe('project-101'); + }); + + it('should handle undefined fields', () => { + const result: Result = { + id: 'result-123', + }; + + const json = ResultToJSON(result); + + expect(json.id).toBe('result-123'); + expect(json.test_id).toBeUndefined(); + }); + + it('should return null when passed null', () => { + const json = ResultToJSON(null); + expect(json).toBeNull(); + }); + + it('should return undefined when passed undefined', () => { + const json = ResultToJSON(undefined); + expect(json).toBeUndefined(); + }); + }); + + describe('instanceOfResult', () => { + it('should return true for any object (as per implementation)', () => { + expect(instanceOfResult({})).toBe(true); + expect(instanceOfResult({ id: 'test' })).toBe(true); + expect( + instanceOfResult({ + id: 'result-123', + testId: 'test-456', + }) + ).toBe(true); + }); + }); + + describe('JSON round-trip', () => { + it('should maintain data integrity through JSON conversion round-trip', () => { + const original: Result = { + id: 'result-123', + testId: 'test-456', + startTime: '2024-01-01T00:00:00Z', + duration: 10.5, + result: ResultResultEnum.Failed, + component: 'backend', + env: 'staging', + runId: 'run-789', + projectId: 'project-101', + metadata: { retry: true }, + params: { timeout: 60 }, + source: 'pytest', + }; + + const json = ResultToJSON(original); + const restored = ResultFromJSON(json); + + expect(restored).toEqual(original); + }); + }); +}); + diff --git a/src/models/__tests__/Run.test.ts b/src/models/__tests__/Run.test.ts new file mode 100644 index 0000000..4aca4ce --- /dev/null +++ b/src/models/__tests__/Run.test.ts @@ -0,0 +1,228 @@ +import { Run, RunFromJSON, RunToJSON, instanceOfRun } from '../Run'; + +describe('Run Model', () => { + describe('interface and types', () => { + it('should create a valid Run object with all fields', () => { + const run: Run = { + id: 'run-123', + created: '2024-01-01T00:00:00Z', + duration: 120.5, + source: 'pytest', + startTime: '2024-01-01T00:00:00Z', + component: 'backend', + env: 'production', + projectId: 'project-456', + summary: { + passed: 10, + failed: 2, + skipped: 1, + }, + metadata: { + build: '1234', + branch: 'main', + }, + }; + + expect(run.id).toBe('run-123'); + expect(run.duration).toBe(120.5); + expect(run.projectId).toBe('project-456'); + expect(run.summary).toEqual({ + passed: 10, + failed: 2, + skipped: 1, + }); + }); + + it('should create a Run object with minimal fields', () => { + const run: Run = {}; + expect(run).toBeDefined(); + }); + + it('should allow null values for nullable fields', () => { + const run: Run = { + id: 'run-123', + source: null, + component: null, + env: null, + projectId: null, + metadata: null, + }; + + expect(run.source).toBeNull(); + expect(run.component).toBeNull(); + expect(run.env).toBeNull(); + expect(run.projectId).toBeNull(); + expect(run.metadata).toBeNull(); + }); + }); + + describe('RunFromJSON', () => { + it('should convert JSON with snake_case to Run object with camelCase', () => { + const json = { + id: 'run-123', + created: '2024-01-01T00:00:00Z', + duration: 120.5, + source: 'pytest', + start_time: '2024-01-01T00:00:00Z', + component: 'backend', + env: 'production', + project_id: 'project-456', + summary: { + passed: 10, + failed: 2, + }, + metadata: { + build: '1234', + }, + }; + + const run = RunFromJSON(json); + + expect(run.id).toBe('run-123'); + expect(run.created).toBe('2024-01-01T00:00:00Z'); + expect(run.startTime).toBe('2024-01-01T00:00:00Z'); + expect(run.projectId).toBe('project-456'); + expect(run.summary).toEqual({ passed: 10, failed: 2 }); + }); + + it('should handle null values correctly', () => { + const json = { + id: 'run-123', + source: null, + component: null, + metadata: null, + }; + + const run = RunFromJSON(json); + + expect(run.id).toBe('run-123'); + expect(run.source).toBeUndefined(); + expect(run.component).toBeUndefined(); + expect(run.metadata).toBeUndefined(); + }); + + it('should handle missing fields as undefined', () => { + const json = { + id: 'run-123', + }; + + const run = RunFromJSON(json); + + expect(run.id).toBe('run-123'); + expect(run.duration).toBeUndefined(); + expect(run.summary).toBeUndefined(); + }); + + it('should return null when passed null', () => { + const run = RunFromJSON(null); + expect(run).toBeNull(); + }); + }); + + describe('RunToJSON', () => { + it('should convert Run object with camelCase to JSON with snake_case', () => { + const run: Run = { + id: 'run-123', + created: '2024-01-01T00:00:00Z', + duration: 120.5, + source: 'pytest', + startTime: '2024-01-01T00:00:00Z', + component: 'backend', + env: 'production', + projectId: 'project-456', + summary: { passed: 10, failed: 2 }, + metadata: { build: '1234' }, + }; + + const json = RunToJSON(run); + + expect(json.id).toBe('run-123'); + expect(json.created).toBe('2024-01-01T00:00:00Z'); + expect(json.start_time).toBe('2024-01-01T00:00:00Z'); + expect(json.project_id).toBe('project-456'); + expect(json.summary).toEqual({ passed: 10, failed: 2 }); + }); + + it('should handle undefined fields', () => { + const run: Run = { + id: 'run-123', + }; + + const json = RunToJSON(run); + + expect(json.id).toBe('run-123'); + expect(json.duration).toBeUndefined(); + }); + + it('should return null when passed null', () => { + const json = RunToJSON(null); + expect(json).toBeNull(); + }); + + it('should return undefined when passed undefined', () => { + const json = RunToJSON(undefined); + expect(json).toBeUndefined(); + }); + }); + + describe('instanceOfRun', () => { + it('should return true for any object (as per implementation)', () => { + expect(instanceOfRun({})).toBe(true); + expect(instanceOfRun({ id: 'test' })).toBe(true); + expect( + instanceOfRun({ + id: 'run-123', + duration: 120, + }) + ).toBe(true); + }); + }); + + describe('JSON round-trip', () => { + it('should maintain data integrity through JSON conversion round-trip', () => { + const original: Run = { + id: 'run-123', + created: '2024-01-01T00:00:00Z', + duration: 120.5, + source: 'pytest', + startTime: '2024-01-01T00:00:00Z', + component: 'backend', + env: 'staging', + projectId: 'project-456', + summary: { + passed: 15, + failed: 3, + skipped: 2, + }, + metadata: { + build: '5678', + branch: 'develop', + }, + }; + + const json = RunToJSON(original); + const restored = RunFromJSON(json); + + expect(restored).toEqual(original); + }); + + it('should handle complex summary objects through round-trip', () => { + const original: Run = { + id: 'run-123', + summary: { + total: 100, + passed: 85, + failed: 10, + skipped: 5, + error: 0, + }, + }; + + const json = RunToJSON(original); + const restored = RunFromJSON(json); + + expect(restored.summary).toEqual(original.summary); + }); + }); +}); + diff --git a/yarn.lock b/yarn.lock index 2dbfc33..e1ba443 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -479,6 +479,13 @@ dependencies: "@jest/get-type" "30.1.0" +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + "@jest/expect@30.2.0": version "30.2.0" resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.2.0.tgz#9a5968499bb8add2bbb09136f69f7df5ddbf3185" @@ -558,6 +565,13 @@ dependencies: "@sinclair/typebox" "^0.34.0" +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + "@jest/snapshot-utils@30.2.0": version "30.2.0" resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz#387858eb90c2f98f67bff327435a532ac5309fbe" @@ -631,6 +645,18 @@ "@types/yargs" "^17.0.33" chalk "^4.1.2" +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" @@ -705,6 +731,11 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + "@sinclair/typebox@^0.34.0": version "0.34.41" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.41.tgz#aa51a6c1946df2c5a11494a2cdb9318e026db16c" @@ -769,7 +800,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -781,13 +812,21 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.4": +"@types/istanbul-reports@^3.0.0", "@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@^29.5.14": + version "29.5.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" + integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -800,7 +839,7 @@ dependencies: undici-types "~7.16.0" -"@types/stack-utils@^2.0.3": +"@types/stack-utils@^2.0.0", "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -810,7 +849,7 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== -"@types/yargs@^17.0.33": +"@types/yargs@^17.0.33", "@types/yargs@^17.0.8": version "17.0.34" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.34.tgz#1c2f9635b71d5401827373a01ce2e8a7670ea839" integrity sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A== @@ -1061,7 +1100,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.2.0: +ansi-styles@^5.0.0, ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== @@ -1194,6 +1233,13 @@ browserslist@^4.24.0: node-releases "^2.0.26" update-browserslist-db "^1.1.4" +bs-logger@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + bser@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" @@ -1239,6 +1285,11 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +ci-info@^3.2.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + ci-info@^4.2.0: version "4.3.1" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa" @@ -1326,6 +1377,11 @@ detect-newline@^3.1.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -1506,6 +1562,17 @@ expect@30.2.0: jest-mock "30.2.0" jest-util "30.2.0" +expect@^29.0.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== + dependencies: + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -1522,7 +1589,7 @@ fast-glob@^3.3.2: merge2 "^1.3.0" micromatch "^4.0.8" -fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -1670,7 +1737,7 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -graceful-fs@^4.2.11: +graceful-fs@^4.2.11, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -1680,6 +1747,18 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +handlebars@^4.7.8: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -1923,6 +2002,16 @@ jest-diff@30.2.0: chalk "^4.1.2" pretty-format "30.2.0" +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-docblock@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.2.0.tgz#42cd98d69f887e531c7352309542b1ce4ee10256" @@ -1954,6 +2043,11 @@ jest-environment-node@30.2.0: jest-util "30.2.0" jest-validate "30.2.0" +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + jest-haste-map@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.2.0.tgz#808e3889f288603ac70ff0ac047598345a66022e" @@ -1990,6 +2084,16 @@ jest-matcher-utils@30.2.0: jest-diff "30.2.0" pretty-format "30.2.0" +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-message-util@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" @@ -2005,6 +2109,21 @@ jest-message-util@30.2.0: slash "^3.0.0" stack-utils "^2.0.6" +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" @@ -2141,6 +2260,18 @@ jest-util@30.2.0: graceful-fs "^4.2.11" picomatch "^4.0.2" +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.2.0.tgz#273eaaed4c0963b934b5b31e96289edda6e0a2ef" @@ -2277,6 +2408,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -2301,6 +2437,11 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" +make-error@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + makeerror@1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" @@ -2318,7 +2459,7 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -micromatch@^4.0.8: +micromatch@^4.0.4, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -2345,6 +2486,11 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" @@ -2365,6 +2511,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -2496,7 +2647,7 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -2537,6 +2688,15 @@ pretty-format@30.2.0: ansi-styles "^5.2.0" react-is "^18.3.1" +pretty-format@^29.0.0, pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -2552,7 +2712,7 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -react-is@^18.3.1: +react-is@^18.0.0, react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== @@ -2596,7 +2756,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2: +semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2, semver@^7.7.3: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== @@ -2636,7 +2796,7 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0: +source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -2646,7 +2806,7 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stack-utils@^2.0.6: +stack-utils@^2.0.3, stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -2771,6 +2931,21 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== +ts-jest@^29.2.5: + version "29.4.5" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.5.tgz#a6b0dc401e521515d5342234be87f1ca96390a6f" + integrity sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q== + dependencies: + bs-logger "^0.2.6" + fast-json-stable-stringify "^2.1.0" + handlebars "^4.7.8" + json5 "^2.2.3" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.7.3" + type-fest "^4.41.0" + yargs-parser "^21.1.1" + tslib@^2.4.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -2793,6 +2968,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + typescript-eslint@^8.18.0: version "8.46.3" resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.3.tgz#d58b337e4c6083ddef9a06542a03768a0150c564" @@ -2808,6 +2988,11 @@ typescript@^5.7.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + undici-types@~7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" @@ -2883,6 +3068,11 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" From 994ce8240278dd1638915a1c4a65187feb617cb8 Mon Sep 17 00:00:00 2001 From: mshriver Date: Wed, 5 Nov 2025 18:11:53 +0100 Subject: [PATCH 2/5] Add test coverage --- .github/workflows/build.yml | 26 +++ AGENTS.md | 19 ++ README.md | 3 + TESTING.md | 323 ++++++++++++++++++++++++++ codecov.yml | 31 +++ eslint.config.mjs | 43 ++++ jest.config.js | 20 +- package.json | 7 +- prettier.config.mjs | 10 + src/__tests__/test-utils.ts | 24 +- src/apis/__tests__/ProjectApi.test.ts | 50 ++-- src/apis/__tests__/ResultApi.test.ts | 75 +++--- src/models/__tests__/Project.test.ts | 39 ++-- src/models/__tests__/Result.test.ts | 34 ++- src/models/__tests__/Run.test.ts | 34 ++- 15 files changed, 639 insertions(+), 99 deletions(-) create mode 100644 AGENTS.md create mode 100644 TESTING.md create mode 100644 codecov.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e3f126..fa1eb71 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,3 +48,29 @@ jobs: - name: Build TypeScript run: yarn build + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run tests with coverage + run: yarn test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a65b9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +- Use `npm` or `yarn` for interacting with the project's building and package management +- Use `npm run lint` or `npm run lint:fix` to check and auto-fix lint issues +- Use `npm run format` or `npm run format:check` for code formatting with Prettier +- Automatically work to resolve failures in the lint and format output +- Do not include excessive emoji in readme, contributing, and other documentation files +- run `nvm use` to ensure the correct node version is available when running commands + +## Testing instructions +- From the package root you can run `npm test` or `npm run test:coverage` +- The commit should pass all tests before proceeding +- Add or update tests for the code you change, even if nobody asked + +## Building +- Build the project with `npm run build` +- The TypeScript compiler will output to the `dist/` directory + +## Pre-commit hooks +- Run `npm run precommit` to execute linting and formatting +- Pre-commit hooks will automatically run on commit if configured diff --git a/README.md b/README.md index ceba5c6..8883596 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # @ibutsu/client +[![Build](https://github.com/ibutsu/ibutsu-client-javascript/workflows/Build/badge.svg)](https://github.com/ibutsu/ibutsu-client-javascript/actions/workflows/build.yml) +[![codecov](https://codecov.io/gh/ibutsu/ibutsu-client-javascript/branch/main/graph/badge.svg)](https://codecov.io/gh/ibutsu/ibutsu-client-javascript) + ibutsu - JavaScript client for @ibutsu/client A system to store and query test results This SDK is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..4f149ec --- /dev/null +++ b/TESTING.md @@ -0,0 +1,323 @@ +# Testing Guide for ibutsu-client-javascript + +This document provides comprehensive information about the testing infrastructure for the TypeScript client library. + +## Overview + +The project uses **Jest** with **ts-jest** for unit testing TypeScript models and API client modules. The testing strategy focuses on: + +- **Model Tests**: Validating TypeScript interfaces, type conversions (camelCase ↔ snake_case), and JSON serialization +- **API Tests**: Testing HTTP request formation, response parsing, error handling, and authentication +- **Test Utilities**: Reusable mocking helpers for consistent test patterns + +## Test Structure + +``` +src/ +├── __tests__/ +│ └── test-utils.ts # Shared test utilities and mock helpers +├── models/ +│ ├── __tests__/ +│ │ ├── Result.test.ts # Tests for Result model +│ │ ├── Project.test.ts # Tests for Project model +│ │ └── Run.test.ts # Tests for Run model +│ └── *.ts # Model files +└── apis/ + ├── __tests__/ + │ ├── ProjectApi.test.ts # Tests for ProjectApi + │ └── ResultApi.test.ts # Tests for ResultApi + └── *.ts # API files +``` + +## Running Tests + +### Basic Test Commands + +```bash +# Run all tests +yarn test + +# Run tests in watch mode (re-runs on file changes) +yarn test:watch + +# Run tests with coverage report +yarn test:coverage +``` + +### Test Configuration + +The Jest configuration is in `jest.config.js`: + +- **Preset**: `ts-jest` for TypeScript support +- **Test Environment**: Node.js +- **Test Pattern**: `**/__tests__/**/*.test.ts` +- **Coverage**: HTML, LCOV, and text reports in `coverage/` directory + +## Current Test Coverage + +As of the initial implementation: + +- **3 Model Tests**: Result, Project, Run (out of 36 models) +- **2 API Tests**: ProjectApi, ResultApi (out of 16 APIs) +- **83 Total Tests**: All passing ✓ +- **Coverage**: ~26% overall + - Models tested: 100% coverage (Result.ts, Project.ts, Run.ts) + - APIs tested: ~80% coverage (ProjectApi.ts, ResultApi.ts) + +### Coverage Goals + +The project currently has baseline coverage thresholds set: +- Statements: 25% +- Branches: 15% +- Functions: 15% +- Lines: 25% + +These should be increased as more tests are added. A recommended target is 60-80% coverage for production code. + +## Writing Tests + +### Model Tests + +Model tests focus on: + +1. **Interface validation**: Type checking and property access +2. **Enum testing**: Verifying all enum values are correct +3. **JSON conversion**: Testing `FromJSON` and `ToJSON` functions +4. **Round-trip conversion**: Ensuring data integrity through conversion cycles +5. **Null/undefined handling**: Testing optional and nullable fields + +Example model test structure: + +```typescript +import { MyModel, MyModelFromJSON, MyModelToJSON } from '../MyModel'; + +describe('MyModel', () => { + describe('interface and types', () => { + it('should create a valid model with all fields', () => { + const model: MyModel = { /* ... */ }; + expect(model.field).toBe('value'); + }); + }); + + describe('MyModelFromJSON', () => { + it('should convert JSON with snake_case to camelCase', () => { + const json = { my_field: 'value' }; + const model = MyModelFromJSON(json); + expect(model.myField).toBe('value'); + }); + }); + + describe('JSON round-trip', () => { + it('should maintain data integrity', () => { + const original: MyModel = { /* ... */ }; + const json = MyModelToJSON(original); + const restored = MyModelFromJSON(json); + expect(restored).toEqual(original); + }); + }); +}); +``` + +### API Tests + +API tests focus on: + +1. **Request formation**: URL construction, headers, body serialization +2. **Response parsing**: Correct handling of API responses +3. **HTTP methods**: GET, POST, PUT, DELETE operations +4. **Query parameters**: Pagination, filtering, etc. +5. **Authentication**: Bearer token handling +6. **Error handling**: 4xx, 5xx responses, network errors + +Example API test structure: + +```typescript +import { MyApi } from '../MyApi'; +import { Configuration } from '../../runtime'; +import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; + +describe('MyApi', () => { + let api: MyApi; + let mockFetch: jest.Mock; + + beforeEach(() => { + setupFetchMock(); + const config = new Configuration({ basePath: 'http://localhost/api' }); + api = new MyApi(config); + }); + + afterEach(() => { + restoreFetch(); + }); + + describe('getData', () => { + it('should fetch data', async () => { + const mockData = { id: '123', name: 'test' }; + mockFetch = createMockFetch(mockData); + global.fetch = mockFetch; + + const result = await api.getData({ id: '123' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost/api/data/123', + expect.objectContaining({ method: 'GET' }) + ); + expect(result.id).toBe('123'); + }); + }); +}); +``` + +### Important Testing Notes + +1. **Snake Case in Mocks**: API responses from the server use `snake_case`, so mock data should use snake_case to match real API behavior: + ```typescript + const mockResponse = { + test_id: 'test-123', // ✓ Correct + testId: 'test-123' // ✗ Incorrect + }; + ``` + +2. **URL Endpoints**: Verify actual API URLs by checking the generated code or running integration tests + +3. **Authentication**: Test both authenticated and unauthenticated scenarios + +## Test Utilities + +### Available Helpers + +Located in `src/__tests__/test-utils.ts`: + +- **`createMockConfiguration()`**: Create a mock Configuration object +- **`createMockResponse()`**: Create a mock fetch Response +- **`createMockFetch()`**: Create a single mock fetch function +- **`createMockFetchSequence()`**: Create a fetch mock with multiple responses +- **`setupFetchMock()`**: Initialize global fetch mock +- **`restoreFetch()`**: Clean up fetch mock after tests +- **`validateObjectStructure()`**: Type-safe object validation + +### Example Usage + +```typescript +import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; + +// Setup +beforeEach(() => { + setupFetchMock(); +}); + +afterEach(() => { + restoreFetch(); +}); + +// In your test +const mockData = { id: '123' }; +const mockFetch = createMockFetch(mockData, 200); +global.fetch = mockFetch; +``` + +## Expanding Test Coverage + +To add tests for additional models or APIs: + +### 1. For a New Model + +```bash +# Create test file +touch src/models/__tests__/NewModel.test.ts +``` + +Use existing model tests as templates (Result.test.ts, Project.test.ts, Run.test.ts). + +### 2. For a New API + +```bash +# Create test file +touch src/apis/__tests__/NewApi.test.ts +``` + +Use existing API tests as templates (ProjectApi.test.ts, ResultApi.test.ts). + +### 3. Run Tests to Verify + +```bash +yarn test +``` + +## Integration Testing + +For integration tests against a real Ibutsu server, see the separate integration test file: + +```bash +# Set environment variables +export IBUTSU_API="https://your-server.com/api" +export IBUTSU_TOKEN="your-token" + +# Run integration tests +yarn integration +``` + +See `integration-test.ts` and `README.md` for more details. + +## CI/CD Integration + +The project uses pre-commit hooks for linting and formatting. To run all checks: + +```bash +# Lint check +yarn lint + +# Format check +yarn format:check + +# Auto-fix issues +yarn lint:fix +``` + +## Best Practices + +1. **Test Organization**: Group related tests using nested `describe` blocks +2. **Test Naming**: Use descriptive names that explain what is being tested +3. **Arrange-Act-Assert**: Structure tests with clear setup, execution, and verification phases +4. **Mock Data**: Use realistic mock data that matches actual API responses +5. **Coverage**: Aim for high coverage but focus on meaningful tests over hitting metrics +6. **Edge Cases**: Test null values, empty arrays, error conditions, and boundary conditions +7. **Async/Await**: Always use async/await for asynchronous operations +8. **Isolation**: Each test should be independent and not rely on other tests + +## Troubleshooting + +### Tests Not Running + +- Ensure Node.js 22+ is installed: `node --version` +- Install dependencies: `yarn install` +- Check Jest is installed: `yarn list jest` + +### Mock Fetch Not Working + +- Ensure `setupFetchMock()` is called in `beforeEach` +- Ensure `restoreFetch()` is called in `afterEach` +- Check that `global.fetch = mockFetch` is set before calling the API + +### Coverage Too Low + +- Run `yarn test:coverage` to see detailed coverage report +- Open `coverage/index.html` in a browser for visual coverage report +- Focus on testing critical paths and common use cases first + +## Resources + +- [Jest Documentation](https://jestjs.io/docs/getting-started) +- [ts-jest Documentation](https://kulshekhar.github.io/ts-jest/) +- [Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices) + +## Next Steps + +To improve test coverage: + +1. Add tests for remaining models (33 more models) +2. Add tests for remaining APIs (14 more APIs) +3. Add tests for the runtime module +4. Increase coverage thresholds gradually +5. Add tests for edge cases and error conditions +6. Consider adding integration tests for complex workflows diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..a323f3a --- /dev/null +++ b/codecov.yml @@ -0,0 +1,31 @@ +codecov: + require_ci_to_pass: yes + notify: + wait_for_ci: yes + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: + default: + target: auto + threshold: 1% + base: auto + patch: + default: + target: auto + threshold: 1% + base: auto + +comment: + layout: "reach,diff,flags,tree" + behavior: default + require_changes: no + +ignore: + - "src/**/__tests__/**" + - "src/**/index.ts" + - "**/*.d.ts" diff --git a/eslint.config.mjs b/eslint.config.mjs index 62e0021..564b0d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -204,6 +204,7 @@ export default tseslint.config( // Special rules for auto-generated code { files: ['src/apis/**/*.ts', 'src/models/**/*.ts', 'src/runtime.ts'], + ignores: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], rules: { // Relax some strict rules for auto-generated OpenAPI code '@typescript-eslint/no-explicit-any': 'off', @@ -222,6 +223,48 @@ export default tseslint.config( '@typescript-eslint/prefer-optional-chain': 'off', }, }, + // Strict rules for test files + { + files: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], + rules: { + // Enforce explicit types in tests + '@typescript-eslint/explicit-function-return-type': 'off', // Allow inference for test callbacks + '@typescript-eslint/explicit-module-boundary-types': 'off', // Allow inference for test functions + + // Maintain type safety in tests + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + + // Allow common test patterns + '@typescript-eslint/unbound-method': 'off', // Jest mocks often trigger this + '@typescript-eslint/no-non-null-assertion': 'warn', // Sometimes needed in tests, but warn + + // Enforce good test practices + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/require-await': 'error', + '@typescript-eslint/no-misused-promises': 'error', + + // Code quality in tests + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + + // Best practices + 'no-console': 'off', // Allow console in tests for debugging + 'prefer-const': 'error', + 'no-var': 'error', + }, + }, // Prettier must be last to override any conflicting formatting rules eslintConfigPrettier ); diff --git a/jest.config.js b/jest.config.js index 666c999..aa7e59f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,15 +3,17 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['/src'], - testMatch: ['**/__tests__/**/*.test.ts'], + testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts', '**/*.spec.ts'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts', '!src/**/__tests__/**', + '!src/**/*.test.ts', + '!src/**/*.spec.ts', ], coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'], + coverageReporters: ['text', 'text-summary', 'lcov', 'html', 'json'], coverageThreshold: { global: { branches: 15, @@ -20,8 +22,20 @@ module.exports = { statements: 25, }, }, + coveragePathIgnorePatterns: [ + '/node_modules/', + '/__tests__/', + '/dist/', + '.test.ts$', + '.spec.ts$', + ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], verbose: true, testTimeout: 10000, + errorOnDeprecated: true, + bail: false, + maxWorkers: '50%', + clearMocks: true, + resetMocks: false, + restoreMocks: false, }; - diff --git a/package.json b/package.json index 848e228..1d353e2 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,11 @@ "integration:run": "node integration-test.js", "integration": "yarn build && yarn integration:build && yarn integration:run", "lint": "yarn lint:eslint && yarn format:check", - "lint:eslint": "eslint src/**/*.ts", - "lint:fix": "eslint --fix src/**/*.ts && yarn format", + "lint:eslint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\" && yarn format", "format": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"" + "format:check": "prettier --check \"src/**/*.ts\"", + "precommit": "yarn lint:fix && yarn test" }, "repository": { "type": "git", diff --git a/prettier.config.mjs b/prettier.config.mjs index 16cad62..d5b0f10 100644 --- a/prettier.config.mjs +++ b/prettier.config.mjs @@ -1,9 +1,19 @@ /** @type {import("prettier").Config} */ export default { + // Strict formatting rules semi: true, trailingComma: 'es5', singleQuote: true, printWidth: 100, tabWidth: 2, arrowParens: 'always', + useTabs: false, + endOfLine: 'lf', + bracketSpacing: true, + bracketSameLine: false, + quoteProps: 'as-needed', + proseWrap: 'preserve', + htmlWhitespaceSensitivity: 'css', + embeddedLanguageFormatting: 'auto', + singleAttributePerLine: false, }; diff --git a/src/__tests__/test-utils.ts b/src/__tests__/test-utils.ts index 98ac759..35098dd 100644 --- a/src/__tests__/test-utils.ts +++ b/src/__tests__/test-utils.ts @@ -30,12 +30,12 @@ export function createMockResponse( 'Content-Type': 'application/json', ...headers, }), - json: async () => data, - text: async () => JSON.stringify(data), - blob: async () => new Blob([JSON.stringify(data)]), - arrayBuffer: async () => new ArrayBuffer(0), - formData: async () => new FormData(), - clone: function () { + json: async () => Promise.resolve(data), + text: async () => Promise.resolve(JSON.stringify(data)), + blob: async () => Promise.resolve(new Blob([JSON.stringify(data)])), + arrayBuffer: async () => Promise.resolve(new ArrayBuffer(0)), + formData: async () => Promise.resolve(new FormData()), + clone() { return this; }, } as Response; @@ -58,9 +58,7 @@ export function createMockFetchSequence( ): jest.Mock { const mockFetch = jest.fn(); responses.forEach((response) => { - mockFetch.mockResolvedValueOnce( - createMockResponse(response.data, response.status ?? 200) - ); + mockFetch.mockResolvedValueOnce(createMockResponse(response.data, response.status ?? 200)); }); return mockFetch; } @@ -76,7 +74,7 @@ export function setupFetchMock(): void { * Restore fetch after tests */ export function restoreFetch(): void { - if (global.fetch && jest.isMockFunction(global.fetch)) { + if (jest.isMockFunction(global.fetch)) { (global.fetch as jest.Mock).mockRestore(); } } @@ -84,13 +82,9 @@ export function restoreFetch(): void { /** * Helper to validate that an object matches a type structure */ -export function validateObjectStructure( - obj: unknown, - expectedKeys: Array -): obj is T { +export function validateObjectStructure(obj: unknown, expectedKeys: Array): obj is T { if (typeof obj !== 'object' || obj === null) { return false; } return expectedKeys.every((key) => key in obj); } - diff --git a/src/apis/__tests__/ProjectApi.test.ts b/src/apis/__tests__/ProjectApi.test.ts index 8646650..d5517c2 100644 --- a/src/apis/__tests__/ProjectApi.test.ts +++ b/src/apis/__tests__/ProjectApi.test.ts @@ -1,5 +1,5 @@ import { ProjectApi } from '../ProjectApi'; -import { Project, ProjectList } from '../../models'; +import type { Project, ProjectList } from '../../models'; import { Configuration } from '../../runtime'; import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; @@ -37,11 +37,15 @@ describe('ProjectApi', () => { const result = await api.addProject({ project: newProject }); expect(mockFetch).toHaveBeenCalledTimes(1); + interface FetchOptions { + method: string; + headers: Record; + } expect(mockFetch).toHaveBeenCalledWith( 'http://localhost/api/project', - expect.objectContaining({ + expect.objectContaining>({ method: 'POST', - headers: expect.objectContaining({ + headers: expect.objectContaining>({ 'Content-Type': 'application/json', }), }) @@ -63,8 +67,11 @@ describe('ProjectApi', () => { await api.addProject({ project: newProject }); - const callArgs = mockFetch.mock.calls[0]; - const body = JSON.parse(callArgs[1].body); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const bodyString = callArgs[1].body as string; + const body: { name?: string; owner_id?: string; group_id?: string } = JSON.parse( + bodyString + ) as { name?: string; owner_id?: string; group_id?: string }; expect(body).toHaveProperty('name', 'test-project'); expect(body).toHaveProperty('owner_id', 'user-123'); @@ -111,7 +118,10 @@ describe('ProjectApi', () => { it('should require id parameter', async () => { // TypeScript should catch this, but testing runtime behavior - await expect(api.getProject({ id: null as any })).rejects.toThrow(); + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api.getProject({ id: null as any }) + ).rejects.toThrow(); }); }); @@ -149,9 +159,9 @@ describe('ProjectApi', () => { pageSize: 50, }); - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('page=2'); - expect(url).toContain('pageSize=50'); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('page=2'); + expect(callArgs[0]).toContain('pageSize=50'); }); it('should handle filter parameters', async () => { @@ -163,9 +173,9 @@ describe('ProjectApi', () => { ownerId: 'user-123', }); - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('filter=name%3Dtest'); - expect(url).toContain('ownerId=user-123'); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('filter=name%3Dtest'); + expect(callArgs[0]).toContain('ownerId=user-123'); }); it('should return empty list when no projects exist', async () => { @@ -206,11 +216,15 @@ describe('ProjectApi', () => { }); expect(mockFetch).toHaveBeenCalledTimes(1); + interface FetchOptions { + method: string; + headers: Record; + } expect(mockFetch).toHaveBeenCalledWith( 'http://localhost/api/project/project-123', - expect.objectContaining({ + expect.objectContaining>({ method: 'PUT', - headers: expect.objectContaining({ + headers: expect.objectContaining>({ 'Content-Type': 'application/json', }), }) @@ -242,6 +256,7 @@ describe('ProjectApi', () => { it('should require id parameter', async () => { await expect( api.updateProject({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any id: null as any, project: {}, }) @@ -291,7 +306,8 @@ describe('ProjectApi', () => { await authenticatedApi.getProjectList({}); - const headers = mockFetch.mock.calls[0][1].headers; + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = callArgs[1].headers as Record; expect(headers.Authorization).toBe('Bearer test-token-123'); }); @@ -301,9 +317,9 @@ describe('ProjectApi', () => { await api.getProjectList({}); - const headers = mockFetch.mock.calls[0][1].headers; + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = callArgs[1].headers as Record; expect(headers.Authorization).toBeUndefined(); }); }); }); - diff --git a/src/apis/__tests__/ResultApi.test.ts b/src/apis/__tests__/ResultApi.test.ts index d345cec..72a4c4a 100644 --- a/src/apis/__tests__/ResultApi.test.ts +++ b/src/apis/__tests__/ResultApi.test.ts @@ -1,5 +1,5 @@ import { ResultApi } from '../ResultApi'; -import { Result, ResultList, ResultResultEnum } from '../../models'; +import { type Result, type ResultList, ResultResultEnum } from '../../models'; import { Configuration } from '../../runtime'; import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; @@ -42,11 +42,15 @@ describe('ResultApi', () => { const result = await api.addResult({ result: newResult }); expect(mockFetch).toHaveBeenCalledTimes(1); + interface FetchOptions { + method: string; + headers: Record; + } expect(mockFetch).toHaveBeenCalledWith( 'http://localhost/api/result', - expect.objectContaining({ + expect.objectContaining>({ method: 'POST', - headers: expect.objectContaining({ + headers: expect.objectContaining>({ 'Content-Type': 'application/json', }), }) @@ -68,8 +72,19 @@ describe('ResultApi', () => { await api.addResult({ result: newResult }); - const callArgs = mockFetch.mock.calls[0]; - const body = JSON.parse(callArgs[1].body); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const bodyString = callArgs[1].body as string; + const body: { + test_id?: string; + start_time?: string; + run_id?: string; + project_id?: string; + } = JSON.parse(bodyString) as { + test_id?: string; + start_time?: string; + run_id?: string; + project_id?: string; + }; expect(body).toHaveProperty('test_id', 'test-123'); expect(body).toHaveProperty('start_time', '2024-01-01T00:00:00Z'); @@ -155,7 +170,10 @@ describe('ResultApi', () => { }); it('should require id parameter', async () => { - await expect(api.getResult({ id: null as any })).rejects.toThrow(); + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api.getResult({ id: null as any }) + ).rejects.toThrow(); }); }); @@ -201,9 +219,9 @@ describe('ResultApi', () => { pageSize: 100, }); - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('page=3'); - expect(url).toContain('pageSize=100'); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('page=3'); + expect(callArgs[0]).toContain('pageSize=100'); }); it('should handle filter parameters', async () => { @@ -214,9 +232,9 @@ describe('ResultApi', () => { filter: ['result=passed', 'duration>10'], }); - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('filter=result%3Dpassed'); - expect(url).toContain('filter=duration'); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('filter=result%3Dpassed'); + expect(callArgs[0]).toContain('filter=duration'); }); it('should handle estimate parameter', async () => { @@ -230,8 +248,8 @@ describe('ResultApi', () => { estimate: true, }); - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('estimate=true'); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('estimate=true'); }); it('should return empty list when no results exist', async () => { @@ -273,11 +291,15 @@ describe('ResultApi', () => { }); expect(mockFetch).toHaveBeenCalledTimes(1); + interface FetchOptions { + method: string; + headers: Record; + } expect(mockFetch).toHaveBeenCalledWith( 'http://localhost/api/result/result-123', - expect.objectContaining({ + expect.objectContaining>({ method: 'PUT', - headers: expect.objectContaining({ + headers: expect.objectContaining>({ 'Content-Type': 'application/json', }), }) @@ -333,6 +355,7 @@ describe('ResultApi', () => { it('should require id parameter', async () => { await expect( api.updateResult({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any id: null as any, result: {}, }) @@ -346,16 +369,11 @@ describe('ResultApi', () => { global.fetch = mockFetch; await api.getResultList({ - filter: [ - 'result=passed', - 'duration>5', - 'env=production', - 'metadata.browser~chrome', - ], + filter: ['result=passed', 'duration>5', 'env=production', 'metadata.browser~chrome'], }); - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('filter='); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('filter='); expect(mockFetch).toHaveBeenCalled(); }); @@ -384,7 +402,8 @@ describe('ResultApi', () => { await authenticatedApi.getResultList({}); - const headers = mockFetch.mock.calls[0][1].headers; + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = callArgs[1].headers as Record; expect(headers.Authorization).toBe('Bearer test-token-456'); }); @@ -394,7 +413,8 @@ describe('ResultApi', () => { await api.getResultList({}); - const headers = mockFetch.mock.calls[0][1].headers; + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = callArgs[1].headers as Record; expect(headers.Authorization).toBeUndefined(); }); }); @@ -418,7 +438,7 @@ describe('ResultApi', () => { mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, - json: () => Promise.reject(new Error('Invalid JSON')), + json: async () => Promise.reject(new Error('Invalid JSON')), }); global.fetch = mockFetch; @@ -426,4 +446,3 @@ describe('ResultApi', () => { }); }); }); - diff --git a/src/models/__tests__/Project.test.ts b/src/models/__tests__/Project.test.ts index 465f2a7..95ee920 100644 --- a/src/models/__tests__/Project.test.ts +++ b/src/models/__tests__/Project.test.ts @@ -1,9 +1,4 @@ -import { - Project, - ProjectFromJSON, - ProjectToJSON, - instanceOfProject, -} from '../Project'; +import { type Project, ProjectFromJSON, ProjectToJSON, instanceOfProject } from '../Project'; describe('Project Model', () => { describe('interface and types', () => { @@ -104,7 +99,19 @@ describe('Project Model', () => { groupId: 'group-789', }; - const json = ProjectToJSON(project); + const json: { + id?: string; + name?: string; + title?: string; + owner_id?: string; + group_id?: string; + } = ProjectToJSON(project) as { + id?: string; + name?: string; + title?: string; + owner_id?: string; + group_id?: string; + }; expect(json.id).toBe('project-123'); expect(json.name).toBe('my-project'); @@ -118,19 +125,22 @@ describe('Project Model', () => { name: 'my-project', }; - const json = ProjectToJSON(project); + const json: { name?: string; id?: string } = ProjectToJSON(project) as { + name?: string; + id?: string; + }; expect(json.name).toBe('my-project'); expect(json.id).toBeUndefined(); }); it('should return null when passed null', () => { - const json = ProjectToJSON(null); + const json: unknown = ProjectToJSON(null); expect(json).toBeNull(); }); it('should return undefined when passed undefined', () => { - const json = ProjectToJSON(undefined); + const json: unknown = ProjectToJSON(undefined); expect(json).toBeUndefined(); }); }); @@ -158,8 +168,8 @@ describe('Project Model', () => { groupId: 'group-789', }; - const json = ProjectToJSON(original); - const restored = ProjectFromJSON(json); + const json: unknown = ProjectToJSON(original); + const restored: Project = ProjectFromJSON(json); expect(restored).toEqual(original); }); @@ -169,11 +179,10 @@ describe('Project Model', () => { name: 'minimal-project', }; - const json = ProjectToJSON(original); - const restored = ProjectFromJSON(json); + const json: unknown = ProjectToJSON(original); + const restored: Project = ProjectFromJSON(json); expect(restored.name).toBe(original.name); }); }); }); - diff --git a/src/models/__tests__/Result.test.ts b/src/models/__tests__/Result.test.ts index d6add99..9ed73ac 100644 --- a/src/models/__tests__/Result.test.ts +++ b/src/models/__tests__/Result.test.ts @@ -1,5 +1,5 @@ import { - Result, + type Result, ResultResultEnum, ResultFromJSON, ResultToJSON, @@ -146,7 +146,23 @@ describe('Result Model', () => { metadata: { browser: 'chrome' }, }; - const json = ResultToJSON(result); + const json: { + id?: string; + test_id?: string; + start_time?: string; + duration?: number; + result?: string; + run_id?: string; + project_id?: string; + } = ResultToJSON(result) as { + id?: string; + test_id?: string; + start_time?: string; + duration?: number; + result?: string; + run_id?: string; + project_id?: string; + }; expect(json.id).toBe('result-123'); expect(json.test_id).toBe('test-456'); @@ -162,19 +178,22 @@ describe('Result Model', () => { id: 'result-123', }; - const json = ResultToJSON(result); + const json: { id?: string; test_id?: string } = ResultToJSON(result) as { + id?: string; + test_id?: string; + }; expect(json.id).toBe('result-123'); expect(json.test_id).toBeUndefined(); }); it('should return null when passed null', () => { - const json = ResultToJSON(null); + const json: unknown = ResultToJSON(null); expect(json).toBeNull(); }); it('should return undefined when passed undefined', () => { - const json = ResultToJSON(undefined); + const json: unknown = ResultToJSON(undefined); expect(json).toBeUndefined(); }); }); @@ -209,11 +228,10 @@ describe('Result Model', () => { source: 'pytest', }; - const json = ResultToJSON(original); - const restored = ResultFromJSON(json); + const json: unknown = ResultToJSON(original); + const restored: Result = ResultFromJSON(json); expect(restored).toEqual(original); }); }); }); - diff --git a/src/models/__tests__/Run.test.ts b/src/models/__tests__/Run.test.ts index 4aca4ce..10de0db 100644 --- a/src/models/__tests__/Run.test.ts +++ b/src/models/__tests__/Run.test.ts @@ -1,4 +1,4 @@ -import { Run, RunFromJSON, RunToJSON, instanceOfRun } from '../Run'; +import { type Run, RunFromJSON, RunToJSON, instanceOfRun } from '../Run'; describe('Run Model', () => { describe('interface and types', () => { @@ -134,7 +134,19 @@ describe('Run Model', () => { metadata: { build: '1234' }, }; - const json = RunToJSON(run); + const json: { + id?: string; + created?: string; + start_time?: string; + project_id?: string; + summary?: Record; + } = RunToJSON(run) as { + id?: string; + created?: string; + start_time?: string; + project_id?: string; + summary?: Record; + }; expect(json.id).toBe('run-123'); expect(json.created).toBe('2024-01-01T00:00:00Z'); @@ -148,19 +160,22 @@ describe('Run Model', () => { id: 'run-123', }; - const json = RunToJSON(run); + const json: { id?: string; duration?: number } = RunToJSON(run) as { + id?: string; + duration?: number; + }; expect(json.id).toBe('run-123'); expect(json.duration).toBeUndefined(); }); it('should return null when passed null', () => { - const json = RunToJSON(null); + const json: unknown = RunToJSON(null); expect(json).toBeNull(); }); it('should return undefined when passed undefined', () => { - const json = RunToJSON(undefined); + const json: unknown = RunToJSON(undefined); expect(json).toBeUndefined(); }); }); @@ -200,8 +215,8 @@ describe('Run Model', () => { }, }; - const json = RunToJSON(original); - const restored = RunFromJSON(json); + const json: unknown = RunToJSON(original); + const restored: Run = RunFromJSON(json); expect(restored).toEqual(original); }); @@ -218,11 +233,10 @@ describe('Run Model', () => { }, }; - const json = RunToJSON(original); - const restored = RunFromJSON(json); + const json: unknown = RunToJSON(original); + const restored: Run = RunFromJSON(json); expect(restored.summary).toEqual(original.summary); }); }); }); - From d169eb27137e9aa36d8f31aee13980a1f810bf74 Mon Sep 17 00:00:00 2001 From: mshriver Date: Thu, 6 Nov 2025 11:14:57 +0100 Subject: [PATCH 3/5] Update test modules and config --- jest.config.js | 1 + jest.setup.ts | 16 + src/__tests__/test-utils.test.ts | 408 ++++++++++++++++++++++++++ src/__tests__/test-utils.ts | 13 +- src/apis/__tests__/ProjectApi.test.ts | 86 +++++- src/apis/__tests__/ResultApi.test.ts | 95 +++++- 6 files changed, 592 insertions(+), 27 deletions(-) create mode 100644 jest.setup.ts create mode 100644 src/__tests__/test-utils.test.ts diff --git a/jest.config.js b/jest.config.js index aa7e59f..33b647e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + setupFilesAfterEnv: ['/jest.setup.ts'], roots: ['/src'], testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts', '**/*.spec.ts'], collectCoverageFrom: [ diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..e206932 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,16 @@ +/** + * Jest global setup file + * This file is run once before all test suites + */ + +import { setupFetchMock, restoreFetch } from './src/__tests__/test-utils'; + +// Setup fetch mock before each test +beforeEach(() => { + setupFetchMock(); +}); + +// Clean up fetch mock after each test +afterEach(() => { + restoreFetch(); +}); diff --git a/src/__tests__/test-utils.test.ts b/src/__tests__/test-utils.test.ts new file mode 100644 index 0000000..4cfc9ef --- /dev/null +++ b/src/__tests__/test-utils.test.ts @@ -0,0 +1,408 @@ +/** + * Tests for test utilities + */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/strict-boolean-expressions */ + +import { + createMockConfiguration, + createMockResponse, + createMockFetch, + createMockFetchSequence, + setupFetchMock, + restoreFetch, + validateObjectStructure, +} from './test-utils'; +import { Configuration } from '../runtime'; + +describe('test-utils', () => { + describe('createMockConfiguration', () => { + it('should create a Configuration with default basePath', () => { + const config = createMockConfiguration(); + + expect(config).toBeInstanceOf(Configuration); + expect(config.basePath).toBe('http://localhost/api'); + }); + + it('should allow overriding basePath', () => { + const config = createMockConfiguration({ + basePath: 'https://example.com/api', + }); + + expect(config.basePath).toBe('https://example.com/api'); + }); + + it('should allow adding accessToken', async () => { + // TypeScript's getter type inference causes a false positive here + // The constructor accepts string, but the getter returns a function + const config = createMockConfiguration({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + accessToken: 'test-token' as any, + }); + + // accessToken getter returns a function, not the raw token + expect(config.accessToken).toBeDefined(); + expect(typeof config.accessToken).toBe('function'); + if (config.accessToken) { + expect(await config.accessToken()).toBe('test-token'); + } + }); + + it('should allow multiple overrides', async () => { + const config = createMockConfiguration({ + basePath: 'https://test.com', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + accessToken: 'token-123' as any, + }); + + expect(config.basePath).toBe('https://test.com'); + expect(config.accessToken).toBeDefined(); + if (config.accessToken) { + expect(await config.accessToken()).toBe('token-123'); + } + }); + }); + + describe('createMockResponse', () => { + it('should create a successful response by default', async () => { + const data = { id: '123', name: 'test' }; + const response = createMockResponse(data); + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(await response.json()).toEqual(data); + }); + + it('should handle 2xx status codes as ok', () => { + const response201 = createMockResponse({ id: '1' }, 201); + const response204 = createMockResponse(null, 204); + + expect(response201.ok).toBe(true); + expect(response201.status).toBe(201); + expect(response204.ok).toBe(true); + expect(response204.status).toBe(204); + }); + + it('should handle error status codes (4xx)', async () => { + const errorData = { error: 'Not Found' }; + const response = createMockResponse(errorData, 404); + + expect(response.ok).toBe(false); + expect(response.status).toBe(404); + expect(response.statusText).toBe('Error'); + expect(await response.json()).toEqual(errorData); + }); + + it('should handle error status codes (5xx)', async () => { + const errorData = { error: 'Internal Server Error' }; + const response = createMockResponse(errorData, 500); + + expect(response.ok).toBe(false); + expect(response.status).toBe(500); + expect(response.statusText).toBe('Error'); + expect(await response.json()).toEqual(errorData); + }); + + it('should handle custom headers', () => { + const response = createMockResponse({ data: 'test' }, 200, { + 'X-Custom-Header': 'custom-value', + }); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + expect(response.headers.get('X-Custom-Header')).toBe('custom-value'); + }); + + it('should allow header overrides', () => { + const response = createMockResponse({ data: 'test' }, 200, { + 'Content-Type': 'text/plain', + }); + + expect(response.headers.get('Content-Type')).toBe('text/plain'); + }); + + it('should implement text() method', async () => { + const data = { id: '123', name: 'test' }; + const response = createMockResponse(data); + + expect(await response.text()).toBe(JSON.stringify(data)); + }); + + it('should implement blob() method', async () => { + const data = { id: '123' }; + const response = createMockResponse(data); + const blob = await response.blob(); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBeGreaterThan(0); + }); + + it('should implement arrayBuffer() method', async () => { + const response = createMockResponse({ data: 'test' }); + const buffer = await response.arrayBuffer(); + + expect(buffer).toBeInstanceOf(ArrayBuffer); + }); + + it('should implement formData() method', async () => { + const response = createMockResponse({ data: 'test' }); + const formData = await response.formData(); + + expect(formData).toBeInstanceOf(FormData); + }); + + it('should implement clone() method that returns a fresh copy', async () => { + const data = { id: '123', mutable: 'original' }; + const response = createMockResponse(data); + const cloned = response.clone(); + + expect(cloned).not.toBe(response); + expect(cloned.status).toBe(response.status); + expect(cloned.ok).toBe(response.ok); + expect(await cloned.json()).toEqual(await response.json()); + }); + + it('should handle edge case: empty object', async () => { + const response = createMockResponse({}); + + expect(response.ok).toBe(true); + expect(await response.json()).toEqual({}); + }); + + it('should handle edge case: null data', async () => { + const response = createMockResponse(null); + + expect(response.ok).toBe(true); + expect(await response.json()).toBe(null); + }); + + it('should handle edge case: array data', async () => { + const data = [1, 2, 3]; + const response = createMockResponse(data); + + expect(await response.json()).toEqual(data); + }); + + it('should handle edge case: string data', async () => { + const data = 'test string'; + const response = createMockResponse(data); + + expect(await response.json()).toBe(data); + expect(await response.text()).toBe('"test string"'); + }); + }); + + describe('createMockFetch', () => { + it('should create a jest mock that returns a response', async () => { + const data = { id: '123' }; + const mockFetch = createMockFetch(data); + + expect(jest.isMockFunction(mockFetch)).toBe(true); + + const response = await mockFetch('http://test.com', {}); + expect(response.ok).toBe(true); + expect(await response.json()).toEqual(data); + }); + + it('should allow custom status codes', async () => { + const mockFetch = createMockFetch({ error: 'Unauthorized' }, 401); + const response = await mockFetch('http://test.com', {}); + + expect(response.ok).toBe(false); + expect(response.status).toBe(401); + }); + + it('should be reusable for multiple calls', async () => { + const data = { id: '123' }; + const mockFetch = createMockFetch(data); + + await mockFetch('http://test.com/1', {}); + await mockFetch('http://test.com/2', {}); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('createMockFetchSequence', () => { + it('should create a mock that returns different responses in sequence', async () => { + const responses = [ + { data: { id: '1' }, status: 200 }, + { data: { id: '2' }, status: 200 }, + { data: { error: 'Not Found' }, status: 404 }, + ]; + + const mockFetch = createMockFetchSequence(responses); + + const response1 = await mockFetch('http://test.com/1', {}); + expect(await response1.json()).toEqual({ id: '1' }); + expect(response1.status).toBe(200); + + const response2 = await mockFetch('http://test.com/2', {}); + expect(await response2.json()).toEqual({ id: '2' }); + expect(response2.status).toBe(200); + + const response3 = await mockFetch('http://test.com/3', {}); + expect(await response3.json()).toEqual({ error: 'Not Found' }); + expect(response3.status).toBe(404); + }); + + it('should default to status 200 when not specified', async () => { + const responses = [{ data: { id: '1' } }, { data: { id: '2' } }]; + + const mockFetch = createMockFetchSequence(responses); + + const response1 = await mockFetch('http://test.com/1', {}); + expect(response1.status).toBe(200); + + const response2 = await mockFetch('http://test.com/2', {}); + expect(response2.status).toBe(200); + }); + + it('should handle empty sequence', () => { + const mockFetch = createMockFetchSequence([]); + + expect(jest.isMockFunction(mockFetch)).toBe(true); + expect(mockFetch.mock.calls.length).toBe(0); + }); + }); + + describe('setupFetchMock and restoreFetch', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should setup global fetch as a jest mock', () => { + setupFetchMock(); + + expect(jest.isMockFunction(global.fetch)).toBe(true); + }); + + it('should allow restoring fetch', () => { + setupFetchMock(); + expect(jest.isMockFunction(global.fetch)).toBe(true); + + restoreFetch(); + + // After restore, if it was a mock, mockRestore should have been called + // We can't easily test the exact state, but we can verify no errors occurred + expect(() => restoreFetch()).not.toThrow(); + }); + + it('should handle restoreFetch when fetch is not mocked', () => { + global.fetch = originalFetch; + + expect(() => restoreFetch()).not.toThrow(); + }); + + it('should be reusable in multiple test contexts', () => { + setupFetchMock(); + const mockFetch1 = global.fetch; + restoreFetch(); + + setupFetchMock(); + const mockFetch2 = global.fetch; + + expect(jest.isMockFunction(mockFetch1)).toBe(true); + expect(jest.isMockFunction(mockFetch2)).toBe(true); + }); + }); + + describe('validateObjectStructure', () => { + interface TestType { + id: string; + name: string; + optional?: number; + } + + it('should return true for valid object structure', () => { + const obj = { id: '123', name: 'test', optional: 42 }; + const result = validateObjectStructure(obj, ['id', 'name']); + + expect(result).toBe(true); + }); + + it('should return true when optional fields are missing', () => { + const obj = { id: '123', name: 'test' }; + const result = validateObjectStructure(obj, ['id', 'name']); + + expect(result).toBe(true); + }); + + it('should return true even with extra fields', () => { + const obj = { id: '123', name: 'test', extra: 'field' }; + const result = validateObjectStructure(obj, ['id', 'name']); + + expect(result).toBe(true); + }); + + it('should return false when required keys are missing', () => { + const obj = { id: '123' }; + const result = validateObjectStructure(obj, ['id', 'name']); + + expect(result).toBe(false); + }); + + it('should return false for null input', () => { + const result = validateObjectStructure(null, ['id', 'name']); + + expect(result).toBe(false); + }); + + it('should return false for undefined input', () => { + const result = validateObjectStructure(undefined, ['id', 'name']); + + expect(result).toBe(false); + }); + + it('should return false for non-object types (string)', () => { + const result = validateObjectStructure('not an object', ['id', 'name']); + + expect(result).toBe(false); + }); + + it('should return false for non-object types (number)', () => { + const result = validateObjectStructure(123, ['id', 'name']); + + expect(result).toBe(false); + }); + + it('should return false for arrays', () => { + const result = validateObjectStructure( + [{ id: '123', name: 'test' }], + ['id', 'name'] + ); + + expect(result).toBe(false); + }); + + it('should handle empty expected keys array', () => { + const obj = { id: '123', name: 'test' }; + const result = validateObjectStructure(obj, []); + + expect(result).toBe(true); + }); + + it('should handle empty object with empty keys', () => { + const obj = {}; + const result = validateObjectStructure(obj, []); + + expect(result).toBe(true); + }); + + it('should handle empty object with required keys', () => { + const obj = {}; + const result = validateObjectStructure(obj, ['id', 'name']); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/__tests__/test-utils.ts b/src/__tests__/test-utils.ts index 35098dd..b3a55af 100644 --- a/src/__tests__/test-utils.ts +++ b/src/__tests__/test-utils.ts @@ -19,10 +19,11 @@ export function createMockConfiguration(overrides?: Partial): Con */ export function createMockResponse( data: T, + // eslint-disable-next-line @typescript-eslint/typedef status = 200, headers: Record = {} ): Response { - const response = { + return { ok: status >= 200 && status < 300, status, statusText: status === 200 ? 'OK' : 'Error', @@ -36,17 +37,19 @@ export function createMockResponse( arrayBuffer: async () => Promise.resolve(new ArrayBuffer(0)), formData: async () => Promise.resolve(new FormData()), clone() { - return this; + return createMockResponse(data, status, headers); }, } as Response; - - return response; } /** * Create a mock fetch function that returns a specific response */ -export function createMockFetch(data: T, status = 200): jest.Mock { +export function createMockFetch( + data: T, + // eslint-disable-next-line @typescript-eslint/typedef + status = 200 +): jest.Mock { return jest.fn().mockResolvedValue(createMockResponse(data, status)); } diff --git a/src/apis/__tests__/ProjectApi.test.ts b/src/apis/__tests__/ProjectApi.test.ts index d5517c2..c3df418 100644 --- a/src/apis/__tests__/ProjectApi.test.ts +++ b/src/apis/__tests__/ProjectApi.test.ts @@ -1,24 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + import { ProjectApi } from '../ProjectApi'; import type { Project, ProjectList } from '../../models'; import { Configuration } from '../../runtime'; -import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; +import { createMockFetch } from '../../__tests__/test-utils'; describe('ProjectApi', () => { let api: ProjectApi; let mockFetch: jest.Mock; beforeEach(() => { - setupFetchMock(); const config = new Configuration({ basePath: 'http://localhost/api', }); api = new ProjectApi(config); }); - afterEach(() => { - restoreFetch(); - }); - describe('addProject', () => { it('should create a new project', async () => { const newProject: Project = { @@ -307,8 +304,8 @@ describe('ProjectApi', () => { await authenticatedApi.getProjectList({}); const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; - const headers = callArgs[1].headers as Record; - expect(headers.Authorization).toBe('Bearer test-token-123'); + const { headers } = callArgs[1]; + expect((headers as Record).Authorization).toBe('Bearer test-token-123'); }); it('should work without authentication when not configured', async () => { @@ -318,8 +315,77 @@ describe('ProjectApi', () => { await api.getProjectList({}); const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; - const headers = callArgs[1].headers as Record; - expect(headers.Authorization).toBeUndefined(); + const { headers } = callArgs[1]; + expect((headers as Record).Authorization).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle 401 Unauthorized errors', async () => { + mockFetch = createMockFetch({ error: 'Unauthorized' }, 401); + global.fetch = mockFetch; + + await expect(api.getProjectList({})).rejects.toThrow(); + }); + + it('should handle 403 Forbidden errors', async () => { + mockFetch = createMockFetch({ error: 'Forbidden' }, 403); + global.fetch = mockFetch; + + await expect(api.getProject({ id: 'project-123' })).rejects.toThrow(); + }); + + it('should handle 500 Internal Server Error', async () => { + mockFetch = createMockFetch({ error: 'Internal Server Error' }, 500); + global.fetch = mockFetch; + + await expect(api.addProject({ project: { name: 'test' } })).rejects.toThrow(); + }); + + it('should handle 503 Service Unavailable', async () => { + mockFetch = createMockFetch({ error: 'Service Unavailable' }, 503); + global.fetch = mockFetch; + + await expect(api.updateProject({ id: 'proj-1', project: {} })).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = mockFetch; + + // Network errors are wrapped in a FetchError by the runtime + await expect(api.getProjectList({})).rejects.toThrow(); + }); + + it('should handle malformed JSON responses', async () => { + mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => Promise.reject(new Error('Invalid JSON')), + }); + global.fetch = mockFetch; + + await expect(api.getProjectList({})).rejects.toThrow(); + }); + + it('should handle empty response body for non-2xx status', async () => { + mockFetch = createMockFetch(null, 400); + global.fetch = mockFetch; + + await expect(api.addProject({ project: {} })).rejects.toThrow(); + }); + + it('should handle malformed error responses', async () => { + mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => Promise.resolve('not an object'), + text: async () => Promise.resolve('not an object'), + }); + global.fetch = mockFetch; + + await expect(api.getProject({ id: 'bad-id' })).rejects.toThrow(); }); }); }); diff --git a/src/apis/__tests__/ResultApi.test.ts b/src/apis/__tests__/ResultApi.test.ts index 72a4c4a..07e6ba8 100644 --- a/src/apis/__tests__/ResultApi.test.ts +++ b/src/apis/__tests__/ResultApi.test.ts @@ -1,24 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + import { ResultApi } from '../ResultApi'; import { type Result, type ResultList, ResultResultEnum } from '../../models'; import { Configuration } from '../../runtime'; -import { createMockFetch, setupFetchMock, restoreFetch } from '../../__tests__/test-utils'; +import { createMockFetch } from '../../__tests__/test-utils'; describe('ResultApi', () => { let api: ResultApi; let mockFetch: jest.Mock; beforeEach(() => { - setupFetchMock(); const config = new Configuration({ basePath: 'http://localhost/api', }); api = new ResultApi(config); }); - afterEach(() => { - restoreFetch(); - }); - describe('addResult', () => { it('should create a new test result', async () => { const newResult: Result = { @@ -403,8 +400,8 @@ describe('ResultApi', () => { await authenticatedApi.getResultList({}); const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; - const headers = callArgs[1].headers as Record; - expect(headers.Authorization).toBe('Bearer test-token-456'); + const { headers } = callArgs[1]; + expect((headers as Record).Authorization).toBe('Bearer test-token-456'); }); it('should work without authentication when not configured', async () => { @@ -414,27 +411,71 @@ describe('ResultApi', () => { await api.getResultList({}); const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; - const headers = callArgs[1].headers as Record; - expect(headers.Authorization).toBeUndefined(); + const { headers } = callArgs[1]; + expect((headers as Record).Authorization).toBeUndefined(); }); }); describe('error handling', () => { - it('should handle server errors', async () => { + it('should handle 400 Bad Request errors', async () => { + mockFetch = createMockFetch({ error: 'Bad Request' }, 400); + global.fetch = mockFetch; + + await expect(api.addResult({ result: {} })).rejects.toThrow(); + }); + + it('should handle 401 Unauthorized errors', async () => { + mockFetch = createMockFetch({ error: 'Unauthorized' }, 401); + global.fetch = mockFetch; + + await expect(api.getResultList({})).rejects.toThrow(); + }); + + it('should handle 403 Forbidden errors', async () => { + mockFetch = createMockFetch({ error: 'Forbidden' }, 403); + global.fetch = mockFetch; + + await expect(api.getResult({ id: 'result-123' })).rejects.toThrow(); + }); + + it('should handle 500 Internal Server Error', async () => { mockFetch = createMockFetch({ error: 'Internal Server Error' }, 500); global.fetch = mockFetch; await expect(api.getResultList({})).rejects.toThrow(); }); + it('should handle 502 Bad Gateway errors', async () => { + mockFetch = createMockFetch({ error: 'Bad Gateway' }, 502); + global.fetch = mockFetch; + + await expect(api.updateResult({ id: 'result-1', result: {} })).rejects.toThrow(); + }); + + it('should handle 503 Service Unavailable', async () => { + mockFetch = createMockFetch({ error: 'Service Unavailable' }, 503); + global.fetch = mockFetch; + + await expect(api.addResult({ result: {} })).rejects.toThrow(); + }); + it('should handle network errors', async () => { mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); global.fetch = mockFetch; + // Network errors are wrapped in a FetchError by the runtime await expect(api.getResultList({})).rejects.toThrow(); }); - it('should handle malformed responses', async () => { + it('should handle timeout errors', async () => { + mockFetch = jest.fn().mockRejectedValue(new Error('Request timeout')); + global.fetch = mockFetch; + + // Timeout errors are wrapped in a FetchError by the runtime + await expect(api.getResult({ id: 'result-123' })).rejects.toThrow(); + }); + + it('should handle malformed JSON responses', async () => { mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, @@ -444,5 +485,35 @@ describe('ResultApi', () => { await expect(api.getResultList({})).rejects.toThrow(); }); + + it('should handle empty response body for non-2xx status', async () => { + mockFetch = createMockFetch(null, 404); + global.fetch = mockFetch; + + await expect(api.getResult({ id: 'missing' })).rejects.toThrow(); + }); + + it('should handle malformed error responses', async () => { + mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => Promise.resolve('not an object'), + text: async () => Promise.resolve('not an object'), + }); + global.fetch = mockFetch; + + await expect(api.addResult({ result: {} })).rejects.toThrow(); + }); + + it('should handle responses with missing required fields', async () => { + mockFetch = createMockFetch({ incomplete: 'data' }); + global.fetch = mockFetch; + + // The API should still return the response even if fields are missing + // The validation happens at the application level + const result = await api.getResult({ id: 'result-123' }); + expect(result).toBeDefined(); + }); }); }); From 10dc4a9209a1aebf11ef4d6aaab0a253c465bdfc Mon Sep 17 00:00:00 2001 From: mshriver Date: Thu, 6 Nov 2025 11:24:07 +0100 Subject: [PATCH 4/5] 2.0.1 for release on GH --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1d353e2..14ea499 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ibutsu-client-ts", - "version": "2.0.0", + "version": "2.0.1", "description": "A TypeScript client for the Ibutsu API", "license": "MIT", "main": "dist/cjs/index.js", From 878852ddc09af52f45a05ac8829189ffa5549d53 Mon Sep 17 00:00:00 2001 From: mshriver Date: Thu, 6 Nov 2025 11:52:09 +0100 Subject: [PATCH 5/5] Config updates from sourcery review --- eslint.config.mjs | 38 ++++++-------------------------------- jest.config.js | 28 ++++++++++++---------------- package.json | 3 +-- 3 files changed, 19 insertions(+), 50 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 564b0d7..6c49eb0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -204,6 +204,7 @@ export default tseslint.config( // Special rules for auto-generated code { files: ['src/apis/**/*.ts', 'src/models/**/*.ts', 'src/runtime.ts'], + // Note: 'ignores' is the correct field for ESLint flat config (not 'excludedFiles') ignores: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], rules: { // Relax some strict rules for auto-generated OpenAPI code @@ -223,46 +224,19 @@ export default tseslint.config( '@typescript-eslint/prefer-optional-chain': 'off', }, }, - // Strict rules for test files + // Rules for test files + // Most strict rules are inherited from base config; only override what differs for tests { files: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], rules: { - // Enforce explicit types in tests - '@typescript-eslint/explicit-function-return-type': 'off', // Allow inference for test callbacks - '@typescript-eslint/explicit-module-boundary-types': 'off', // Allow inference for test functions - - // Maintain type safety in tests - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unsafe-argument': 'error', - '@typescript-eslint/no-unsafe-assignment': 'error', - '@typescript-eslint/no-unsafe-call': 'error', - '@typescript-eslint/no-unsafe-member-access': 'error', - '@typescript-eslint/no-unsafe-return': 'error', + // Allow type inference in test callbacks and helper functions + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', // Allow common test patterns '@typescript-eslint/unbound-method': 'off', // Jest mocks often trigger this '@typescript-eslint/no-non-null-assertion': 'warn', // Sometimes needed in tests, but warn - - // Enforce good test practices - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/require-await': 'error', - '@typescript-eslint/no-misused-promises': 'error', - - // Code quality in tests - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - - // Best practices 'no-console': 'off', // Allow console in tests for debugging - 'prefer-const': 'error', - 'no-var': 'error', }, }, // Prettier must be last to override any conflicting formatting rules diff --git a/jest.config.js b/jest.config.js index 33b647e..88415b8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,17 @@ +const { defaults: tsjPreset } = require('ts-jest/presets'); + /** @type {import('jest').Config} */ module.exports = { - preset: 'ts-jest', + // Spread ts-jest preset defaults (includes transform, moduleFileExtensions, testMatch, etc.) + ...tsjPreset, + + // Override only what we need testEnvironment: 'node', - setupFilesAfterEnv: ['/jest.setup.ts'], roots: ['/src'], + setupFilesAfterEnv: ['/jest.setup.ts'], testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts', '**/*.spec.ts'], + + // Coverage configuration collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', @@ -13,8 +20,6 @@ module.exports = { '!src/**/*.test.ts', '!src/**/*.spec.ts', ], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'text-summary', 'lcov', 'html', 'json'], coverageThreshold: { global: { branches: 15, @@ -23,20 +28,11 @@ module.exports = { statements: 25, }, }, - coveragePathIgnorePatterns: [ - '/node_modules/', - '/__tests__/', - '/dist/', - '.test.ts$', - '.spec.ts$', - ], - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + + // Custom flags that differ from ts-jest defaults verbose: true, testTimeout: 10000, errorOnDeprecated: true, - bail: false, - maxWorkers: '50%', clearMocks: true, - resetMocks: false, - restoreMocks: false, + maxWorkers: '50%', }; diff --git a/package.json b/package.json index 14ea499..726230d 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,7 @@ "lint:eslint": "eslint \"src/**/*.ts\"", "lint:fix": "eslint --fix \"src/**/*.ts\" && yarn format", "format": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"", - "precommit": "yarn lint:fix && yarn test" + "format:check": "prettier --check \"src/**/*.ts\"" }, "repository": { "type": "git",