feat: add comprehensive testing infrastructure
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled

- Add Vitest for unit testing across all packages
- Add Playwright for E2E testing
- Add sample tests for API, Web, and Shared packages
- Configure Gitea Actions CI/CD workflows (ci, e2e, security, docker)
- Add testing documentation (TESTING.md)
- Add Gitea Actions setup guide
- Update .gitignore for test artifacts
- Add test environment configuration
This commit is contained in:
2025-10-28 02:03:52 -06:00
parent 4e71ef9c66
commit 554b53bec7
25 changed files with 3194 additions and 5 deletions

View File

@@ -6,11 +6,18 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"keywords": ["basil", "shared", "types"],
"license": "MIT",
"devDependencies": {
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^1.2.0",
"@vitest/ui": "^1.2.0",
"@vitest/coverage-v8": "^1.2.0"
}
}

View File

@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest';
import type {
Recipe,
Ingredient,
Instruction,
RecipeImportRequest,
RecipeImportResponse,
ApiResponse,
PaginatedResponse,
StorageConfig,
} from './types';
describe('Shared Types', () => {
describe('Recipe Type', () => {
it('should accept valid recipe object', () => {
const recipe: Recipe = {
id: '1',
title: 'Test Recipe',
description: 'A test recipe',
ingredients: [],
instructions: [],
createdAt: new Date(),
updatedAt: new Date(),
};
expect(recipe.id).toBe('1');
expect(recipe.title).toBe('Test Recipe');
});
it('should allow optional fields', () => {
const recipe: Recipe = {
id: '1',
title: 'Minimal Recipe',
ingredients: [],
instructions: [],
createdAt: new Date(),
updatedAt: new Date(),
prepTime: 15,
cookTime: 30,
totalTime: 45,
servings: 4,
cuisine: 'Italian',
category: 'Main Course',
rating: 4.5,
};
expect(recipe.prepTime).toBe(15);
expect(recipe.servings).toBe(4);
expect(recipe.rating).toBe(4.5);
});
});
describe('Ingredient Type', () => {
it('should accept valid ingredient object', () => {
const ingredient: Ingredient = {
name: 'Flour',
amount: '2',
unit: 'cups',
order: 0,
};
expect(ingredient.name).toBe('Flour');
expect(ingredient.amount).toBe('2');
expect(ingredient.unit).toBe('cups');
});
it('should allow optional notes', () => {
const ingredient: Ingredient = {
name: 'Sugar',
order: 1,
notes: 'Can substitute with honey',
};
expect(ingredient.notes).toBe('Can substitute with honey');
});
});
describe('Instruction Type', () => {
it('should accept valid instruction object', () => {
const instruction: Instruction = {
step: 1,
text: 'Mix all ingredients together',
};
expect(instruction.step).toBe(1);
expect(instruction.text).toBe('Mix all ingredients together');
});
it('should allow optional imageUrl', () => {
const instruction: Instruction = {
step: 2,
text: 'Bake for 30 minutes',
imageUrl: '/uploads/instructions/step2.jpg',
};
expect(instruction.imageUrl).toBe('/uploads/instructions/step2.jpg');
});
});
describe('RecipeImportRequest Type', () => {
it('should accept valid import request', () => {
const request: RecipeImportRequest = {
url: 'https://example.com/recipe',
};
expect(request.url).toBe('https://example.com/recipe');
});
});
describe('RecipeImportResponse Type', () => {
it('should accept successful import response', () => {
const response: RecipeImportResponse = {
success: true,
recipe: {
title: 'Imported Recipe',
description: 'A recipe imported from URL',
},
};
expect(response.success).toBe(true);
expect(response.recipe.title).toBe('Imported Recipe');
});
it('should accept failed import response', () => {
const response: RecipeImportResponse = {
success: false,
recipe: {},
error: 'Failed to scrape recipe',
};
expect(response.success).toBe(false);
expect(response.error).toBe('Failed to scrape recipe');
});
});
describe('ApiResponse Type', () => {
it('should accept successful response with data', () => {
const response: ApiResponse<Recipe> = {
data: {
id: '1',
title: 'Test Recipe',
ingredients: [],
instructions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
};
expect(response.data).toBeDefined();
expect(response.data?.title).toBe('Test Recipe');
});
it('should accept error response', () => {
const response: ApiResponse<Recipe> = {
error: 'Recipe not found',
};
expect(response.error).toBe('Recipe not found');
expect(response.data).toBeUndefined();
});
});
describe('PaginatedResponse Type', () => {
it('should accept valid paginated response', () => {
const response: PaginatedResponse<Recipe> = {
data: [
{
id: '1',
title: 'Recipe 1',
ingredients: [],
instructions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 100,
page: 1,
pageSize: 20,
};
expect(response.data).toHaveLength(1);
expect(response.total).toBe(100);
expect(response.page).toBe(1);
expect(response.pageSize).toBe(20);
});
});
describe('StorageConfig Type', () => {
it('should accept local storage config', () => {
const config: StorageConfig = {
type: 'local',
localPath: './uploads',
};
expect(config.type).toBe('local');
expect(config.localPath).toBe('./uploads');
});
it('should accept S3 storage config', () => {
const config: StorageConfig = {
type: 's3',
s3Bucket: 'basil-recipes',
s3Region: 'us-east-1',
s3AccessKey: 'test-key',
s3SecretKey: 'test-secret',
};
expect(config.type).toBe('s3');
expect(config.s3Bucket).toBe('basil-recipes');
expect(config.s3Region).toBe('us-east-1');
});
});
});
// Type guard helper functions (useful utilities to test)
describe('Type Guard Utilities', () => {
const isRecipe = (obj: any): obj is Recipe => {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'string' &&
typeof obj.title === 'string' &&
Array.isArray(obj.ingredients) &&
Array.isArray(obj.instructions)
);
};
const isSuccessResponse = <T>(response: ApiResponse<T>): response is { data: T } => {
return response.data !== undefined && response.error === undefined;
};
it('should validate recipe objects with type guard', () => {
const validRecipe = {
id: '1',
title: 'Test',
ingredients: [],
instructions: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const invalidRecipe = {
id: 1, // wrong type
title: 'Test',
};
expect(isRecipe(validRecipe)).toBe(true);
expect(isRecipe(invalidRecipe)).toBe(false);
});
it('should validate success responses with type guard', () => {
const successResponse: ApiResponse<string> = {
data: 'Success',
};
const errorResponse: ApiResponse<string> = {
error: 'Failed',
};
expect(isSuccessResponse(successResponse)).toBe(true);
expect(isSuccessResponse(errorResponse)).toBe(false);
});
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'dist/',
'**/*.config.ts',
'**/*.d.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});