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

16
packages/api/.env.test Normal file
View File

@@ -0,0 +1,16 @@
# Test Environment Configuration
NODE_ENV=test
PORT=3001
# Test Database
DATABASE_URL=postgresql://basil:basil@localhost:5432/basil_test
# Storage Configuration (use local for tests)
STORAGE_TYPE=local
LOCAL_STORAGE_PATH=./test-uploads
# CORS
CORS_ORIGIN=http://localhost:5173
# Disable external services in tests
DISABLE_EXTERNAL_SERVICES=true

View File

@@ -7,6 +7,10 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
@@ -30,11 +34,16 @@
"@types/cors": "^2.8.17",
"@types/multer": "^1.4.11",
"@types/node": "^20.10.6",
"@types/supertest": "^6.0.2",
"prisma": "^5.8.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0"
"@typescript-eslint/parser": "^6.17.0",
"vitest": "^1.2.0",
"@vitest/ui": "^1.2.0",
"@vitest/coverage-v8": "^1.2.0",
"supertest": "^6.3.4"
}
}

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import recipesRouter from './recipes.routes';
// Mock dependencies
vi.mock('../config/database', () => ({
default: {
recipe: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
recipeImage: {
create: vi.fn(),
},
ingredient: {
deleteMany: vi.fn(),
},
instruction: {
deleteMany: vi.fn(),
},
recipeTag: {
deleteMany: vi.fn(),
},
},
}));
vi.mock('../services/storage.service', () => ({
StorageService: {
getInstance: vi.fn(() => ({
saveFile: vi.fn().mockResolvedValue('/uploads/recipes/test-image.jpg'),
deleteFile: vi.fn().mockResolvedValue(undefined),
})),
},
}));
vi.mock('../services/scraper.service', () => ({
ScraperService: vi.fn(() => ({
scrapeRecipe: vi.fn().mockResolvedValue({
success: true,
recipe: {
title: 'Scraped Recipe',
description: 'A scraped recipe',
sourceUrl: 'https://example.com/recipe',
},
}),
})),
}));
describe('Recipes Routes - Integration Tests', () => {
let app: express.Application;
beforeEach(() => {
vi.clearAllMocks();
app = express();
app.use(express.json());
app.use('/recipes', recipesRouter);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('GET /recipes', () => {
it('should return paginated recipes', async () => {
const mockRecipes = [
{
id: '1',
title: 'Recipe 1',
description: 'Description 1',
ingredients: [],
instructions: [],
images: [],
tags: [],
},
];
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue(mockRecipes as any);
vi.mocked(prisma.default.recipe.count).mockResolvedValue(1);
const response = await request(app).get('/recipes').expect(200);
expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('total', 1);
expect(response.body).toHaveProperty('page', 1);
expect(response.body.data).toHaveLength(1);
});
it('should support search query parameter', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]);
vi.mocked(prisma.default.recipe.count).mockResolvedValue(0);
await request(app).get('/recipes?search=pasta').expect(200);
expect(prisma.default.recipe.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: expect.any(Array),
}),
})
);
});
it('should support pagination parameters', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]);
vi.mocked(prisma.default.recipe.count).mockResolvedValue(0);
await request(app).get('/recipes?page=2&limit=10').expect(200);
expect(prisma.default.recipe.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 10,
take: 10,
})
);
});
});
describe('GET /recipes/:id', () => {
it('should return single recipe by id', async () => {
const mockRecipe = {
id: '1',
title: 'Test Recipe',
description: 'Test Description',
ingredients: [],
instructions: [],
images: [],
tags: [],
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const response = await request(app).get('/recipes/1').expect(200);
expect(response.body.data).toHaveProperty('title', 'Test Recipe');
});
it('should return 404 when recipe not found', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(null);
const response = await request(app).get('/recipes/nonexistent').expect(404);
expect(response.body).toHaveProperty('error', 'Recipe not found');
});
});
describe('POST /recipes', () => {
it('should create new recipe', async () => {
const newRecipe = {
title: 'New Recipe',
description: 'New Description',
ingredients: [{ name: 'Flour', amount: '2 cups' }],
instructions: [{ step: 1, text: 'Mix ingredients' }],
};
const mockCreatedRecipe = {
id: '1',
...newRecipe,
createdAt: new Date(),
updatedAt: new Date(),
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.create).mockResolvedValue(mockCreatedRecipe as any);
const response = await request(app)
.post('/recipes')
.send(newRecipe)
.expect(201);
expect(response.body.data).toHaveProperty('title', 'New Recipe');
expect(prisma.default.recipe.create).toHaveBeenCalled();
});
});
describe('POST /recipes/import', () => {
it('should import recipe from URL', async () => {
const response = await request(app)
.post('/recipes/import')
.send({ url: 'https://example.com/recipe' })
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.recipe).toHaveProperty('title', 'Scraped Recipe');
});
it('should return 400 when URL is missing', async () => {
const response = await request(app).post('/recipes/import').send({}).expect(400);
expect(response.body).toHaveProperty('error', 'URL is required');
});
});
describe('DELETE /recipes/:id', () => {
it('should delete recipe and associated images', async () => {
const mockRecipe = {
id: '1',
title: 'Recipe to Delete',
imageUrl: '/uploads/recipes/main.jpg',
images: [{ url: '/uploads/recipes/image1.jpg' }],
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any);
vi.mocked(prisma.default.recipe.delete).mockResolvedValue(mockRecipe as any);
const response = await request(app).delete('/recipes/1').expect(200);
expect(response.body).toHaveProperty('message', 'Recipe deleted successfully');
expect(prisma.default.recipe.delete).toHaveBeenCalledWith({
where: { id: '1' },
});
});
});
});

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import axios from 'axios';
import { ScraperService } from './scraper.service';
vi.mock('axios');
describe('ScraperService', () => {
let scraperService: ScraperService;
beforeEach(() => {
vi.clearAllMocks();
scraperService = new ScraperService();
});
describe('scrapeRecipe', () => {
it('should extract recipe from schema.org JSON-LD', async () => {
const mockHtml = `
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{
"@type": "Recipe",
"name": "Test Recipe",
"description": "A delicious test recipe",
"prepTime": "PT15M",
"cookTime": "PT30M",
"totalTime": "PT45M",
"recipeYield": "4",
"recipeIngredient": ["2 cups flour", "1 cup sugar"],
"recipeInstructions": [
{"text": "Mix ingredients"},
{"text": "Bake for 30 minutes"}
],
"author": {"name": "Chef Test"},
"recipeCuisine": "Italian",
"recipeCategory": "Dessert"
}
</script>
</head>
<body></body>
</html>
`;
vi.mocked(axios.get).mockResolvedValue({ data: mockHtml });
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
expect(result.success).toBe(true);
expect(result.recipe?.title).toBe('Test Recipe');
expect(result.recipe?.description).toBe('A delicious test recipe');
expect(result.recipe?.prepTime).toBe(15);
expect(result.recipe?.cookTime).toBe(30);
expect(result.recipe?.totalTime).toBe(45);
expect(result.recipe?.servings).toBe(4);
expect(result.recipe?.ingredients).toHaveLength(2);
expect(result.recipe?.instructions).toHaveLength(2);
expect(result.recipe?.sourceUrl).toBe('https://example.com/recipe');
});
it('should fallback to manual parsing when no schema.org found', async () => {
const mockHtml = `
<!DOCTYPE html>
<html>
<head>
<title>Test Recipe Page</title>
<meta name="description" content="Test description">
<meta property="og:image" content="https://example.com/image.jpg">
</head>
<body>
<h1>Fallback Recipe</h1>
</body>
</html>
`;
vi.mocked(axios.get).mockResolvedValue({ data: mockHtml });
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
expect(result.success).toBe(true);
expect(result.recipe?.title).toBe('Fallback Recipe');
expect(result.recipe?.description).toBe('Test description');
expect(result.recipe?.imageUrl).toBe('https://example.com/image.jpg');
});
it('should handle errors gracefully', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('Network error'));
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
expect(result.success).toBe(false);
expect(result.error).toContain('Network error');
});
it('should parse ISO 8601 duration correctly', async () => {
const mockHtml = `
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{
"@type": "Recipe",
"name": "Duration Test",
"prepTime": "PT1H30M",
"cookTime": "PT45M"
}
</script>
</head>
</html>
`;
vi.mocked(axios.get).mockResolvedValue({ data: mockHtml });
const result = await scraperService.scrapeRecipe('https://example.com/recipe');
expect(result.recipe?.prepTime).toBe(90); // 1 hour 30 minutes
expect(result.recipe?.cookTime).toBe(45); // 45 minutes
});
});
describe('downloadImage', () => {
it('should download image and return buffer', async () => {
const mockImageData = Buffer.from('fake-image-data');
vi.mocked(axios.get).mockResolvedValue({ data: mockImageData });
const result = await scraperService.downloadImage('https://example.com/image.jpg');
expect(axios.get).toHaveBeenCalledWith(
'https://example.com/image.jpg',
expect.objectContaining({
responseType: 'arraybuffer',
timeout: 10000,
})
);
expect(result).toBeInstanceOf(Buffer);
});
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import { StorageService } from './storage.service';
// Mock fs/promises
vi.mock('fs/promises');
vi.mock('../config/storage', () => ({
storageConfig: {
type: 'local',
localPath: './test-uploads',
},
}));
describe('StorageService', () => {
let storageService: StorageService;
beforeEach(() => {
vi.clearAllMocks();
storageService = StorageService.getInstance();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('getInstance', () => {
it('should return singleton instance', () => {
const instance1 = StorageService.getInstance();
const instance2 = StorageService.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('saveFile', () => {
it('should save file locally when storage type is local', async () => {
const mockFile = {
originalname: 'test-image.jpg',
buffer: Buffer.from('test-content'),
fieldname: 'image',
encoding: '7bit',
mimetype: 'image/jpeg',
size: 12,
} as Express.Multer.File;
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await storageService.saveFile(mockFile, 'recipes');
expect(fs.mkdir).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
expect(result).toMatch(/^\/uploads\/recipes\/\d+-test-image\.jpg$/);
});
it('should throw error for S3 storage (not implemented)', async () => {
const mockFile = {
originalname: 'test-image.jpg',
buffer: Buffer.from('test-content'),
} as Express.Multer.File;
// Mock S3 storage type
vi.doMock('../config/storage', () => ({
storageConfig: {
type: 's3',
},
}));
await expect(storageService.saveFile(mockFile, 'recipes')).rejects.toThrow(
'S3 storage not yet implemented'
);
});
});
describe('deleteFile', () => {
it('should delete file from local storage', async () => {
const fileUrl = '/uploads/recipes/123-test.jpg';
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await storageService.deleteFile(fileUrl);
expect(fs.unlink).toHaveBeenCalledWith(
expect.stringContaining('recipes/123-test.jpg')
);
});
it('should handle errors when deleting non-existent file', async () => {
const fileUrl = '/uploads/recipes/non-existent.jpg';
const mockError = new Error('File not found');
vi.mocked(fs.unlink).mockRejectedValue(mockError);
// Should not throw, just log error
await expect(storageService.deleteFile(fileUrl)).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,27 @@
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/',
'prisma/',
'**/*.config.ts',
'**/*.d.ts',
],
},
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -0,0 +1,16 @@
import { beforeAll, afterAll } from 'vitest';
import dotenv from 'dotenv';
// Load test environment variables
dotenv.config({ path: '.env.test' });
// Global test setup
beforeAll(() => {
// Setup code before all tests run
// e.g., initialize test database, start test server, etc.
});
afterAll(() => {
// Cleanup code after all tests complete
// e.g., close database connections, stop test server, etc.
});