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
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:
16
packages/api/.env.test
Normal file
16
packages/api/.env.test
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
224
packages/api/src/routes/recipes.routes.test.ts
Normal file
224
packages/api/src/routes/recipes.routes.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
138
packages/api/src/services/scraper.service.test.ts
Normal file
138
packages/api/src/services/scraper.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/api/src/services/storage.service.test.ts
Normal file
96
packages/api/src/services/storage.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
27
packages/api/vitest.config.ts
Normal file
27
packages/api/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
16
packages/api/vitest.setup.ts
Normal file
16
packages/api/vitest.setup.ts
Normal 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.
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
263
packages/shared/src/types.test.ts
Normal file
263
packages/shared/src/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
packages/shared/vitest.config.ts
Normal file
25
packages/shared/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -7,6 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --ext ts,tsx"
|
||||
},
|
||||
"keywords": ["basil", "web"],
|
||||
@@ -28,6 +32,13 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
"vite": "^5.0.10",
|
||||
"vitest": "^1.2.0",
|
||||
"@vitest/ui": "^1.2.0",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"jsdom": "^23.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
181
packages/web/src/pages/RecipeList.test.tsx
Normal file
181
packages/web/src/pages/RecipeList.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import RecipeList from './RecipeList';
|
||||
import { recipesApi } from '../services/api';
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../services/api', () => ({
|
||||
recipesApi: {
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe('RecipeList Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
};
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
vi.mocked(recipesApi.getAll).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
expect(screen.getByText('Loading recipes...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display recipes after loading', async () => {
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Spaghetti Carbonara',
|
||||
description: 'Classic Italian pasta dish',
|
||||
totalTime: 30,
|
||||
servings: 4,
|
||||
imageUrl: '/uploads/recipes/pasta.jpg',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Chocolate Cake',
|
||||
description: 'Rich and moist chocolate cake',
|
||||
totalTime: 60,
|
||||
servings: 8,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Carbonara')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chocolate Cake')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display empty state when no recipes', async () => {
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No recipes yet. Import one from a URL or create your own!/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display error message on API failure', async () => {
|
||||
vi.mocked(recipesApi.getAll).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to recipe detail when card is clicked', async () => {
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test Recipe',
|
||||
description: 'Test Description',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Recipe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const recipeCard = screen.getByText('Test Recipe').closest('.recipe-card');
|
||||
recipeCard?.click();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/recipes/1');
|
||||
});
|
||||
|
||||
it('should display recipe metadata when available', async () => {
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Recipe with Metadata',
|
||||
totalTime: 45,
|
||||
servings: 6,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||
expect(screen.getByText('6 servings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should truncate long descriptions', async () => {
|
||||
const longDescription = 'A'.repeat(150);
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Recipe with Long Description',
|
||||
description: longDescription,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
const description = screen.getByText(/^A{100}\.\.\.$/);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/web/src/services/api.test.ts
Normal file
143
packages/web/src/services/api.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { recipesApi } from './api';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
describe('Recipes API Service', () => {
|
||||
const mockAxios = axios as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAxios.create = vi.fn(() => mockAxios);
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should fetch all recipes with default params', async () => {
|
||||
const mockRecipes = {
|
||||
data: [
|
||||
{ id: '1', title: 'Recipe 1' },
|
||||
{ id: '2', title: 'Recipe 2' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipes });
|
||||
|
||||
const result = await recipesApi.getAll();
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith('/recipes', { params: undefined });
|
||||
expect(result).toEqual(mockRecipes);
|
||||
});
|
||||
|
||||
it('should fetch recipes with search params', async () => {
|
||||
const mockRecipes = {
|
||||
data: [{ id: '1', title: 'Pasta Recipe' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipes });
|
||||
|
||||
await recipesApi.getAll({ search: 'pasta', page: 1, limit: 10 });
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith('/recipes', {
|
||||
params: { search: 'pasta', page: 1, limit: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should fetch single recipe by id', async () => {
|
||||
const mockRecipe = {
|
||||
data: { id: '1', title: 'Test Recipe', description: 'Test' },
|
||||
};
|
||||
|
||||
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipe });
|
||||
|
||||
const result = await recipesApi.getById('1');
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith('/recipes/1');
|
||||
expect(result).toEqual(mockRecipe);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create new recipe', async () => {
|
||||
const newRecipe = { title: 'New Recipe', description: 'New Description' };
|
||||
const mockResponse = { data: { id: '1', ...newRecipe } };
|
||||
|
||||
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.create(newRecipe);
|
||||
|
||||
expect(mockAxios.post).toHaveBeenCalledWith('/recipes', newRecipe);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update existing recipe', async () => {
|
||||
const updatedRecipe = { title: 'Updated Recipe' };
|
||||
const mockResponse = { data: { id: '1', ...updatedRecipe } };
|
||||
|
||||
mockAxios.put = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.update('1', updatedRecipe);
|
||||
|
||||
expect(mockAxios.put).toHaveBeenCalledWith('/recipes/1', updatedRecipe);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete recipe', async () => {
|
||||
const mockResponse = { data: { message: 'Recipe deleted' } };
|
||||
|
||||
mockAxios.delete = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.delete('1');
|
||||
|
||||
expect(mockAxios.delete).toHaveBeenCalledWith('/recipes/1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadImage', () => {
|
||||
it('should upload image with multipart form data', async () => {
|
||||
const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||
const mockResponse = { data: { url: '/uploads/recipes/test.jpg' } };
|
||||
|
||||
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.uploadImage('1', mockFile);
|
||||
|
||||
expect(mockAxios.post).toHaveBeenCalledWith(
|
||||
'/recipes/1/images',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromUrl', () => {
|
||||
it('should import recipe from URL', async () => {
|
||||
const url = 'https://example.com/recipe';
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
recipe: { title: 'Imported Recipe' },
|
||||
};
|
||||
|
||||
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.importFromUrl(url);
|
||||
|
||||
expect(mockAxios.post).toHaveBeenCalledWith('/recipes/import', { url });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
packages/web/vitest.config.ts
Normal file
28
packages/web/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.config.ts',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
11
packages/web/vitest.setup.ts
Normal file
11
packages/web/vitest.setup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { expect, afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Cleanup after each test case (e.g., clearing jsdom)
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Extend Vitest matchers with jest-dom
|
||||
expect.extend({});
|
||||
Reference in New Issue
Block a user