mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
first implementation
This commit is contained in:
parent
98efa6f6e8
commit
74dc6892ab
61 changed files with 30899 additions and 4934 deletions
347
docs/test-examples/README.md
Normal file
347
docs/test-examples/README.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# Test Examples
|
||||
|
||||
This directory contains comprehensive example test files demonstrating best practices for testing different app types in the Manacore monorepo.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
test-examples/
|
||||
├── backend/ # NestJS backend examples
|
||||
│ ├── example.controller.spec.ts
|
||||
│ └── example.service.spec.ts
|
||||
├── mobile/ # React Native mobile examples
|
||||
│ ├── ExampleComponent.test.tsx
|
||||
│ └── authService.test.ts
|
||||
├── web/ # SvelteKit web examples
|
||||
│ ├── Button.test.ts
|
||||
│ └── page.server.test.ts
|
||||
├── shared/ # Shared package examples
|
||||
│ └── format.test.ts
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Example Files Overview
|
||||
|
||||
### Backend Tests (NestJS)
|
||||
|
||||
#### `example.controller.spec.ts`
|
||||
Demonstrates:
|
||||
- Controller unit testing with mocked services
|
||||
- Request/response handling
|
||||
- Authentication/authorization testing
|
||||
- Input validation
|
||||
- Error handling
|
||||
- CRUD operations
|
||||
|
||||
**Key Patterns**:
|
||||
- Use `@nestjs/testing` TestingModule
|
||||
- Mock all service dependencies
|
||||
- Test both success and error paths
|
||||
- Verify service method calls
|
||||
|
||||
#### `example.service.spec.ts`
|
||||
Demonstrates:
|
||||
- Service business logic testing
|
||||
- Database operation mocking
|
||||
- External API mocking
|
||||
- Result pattern for error handling
|
||||
- Data validation and sanitization
|
||||
- Authorization checks
|
||||
|
||||
**Key Patterns**:
|
||||
- Mock database and external services
|
||||
- Test error handling thoroughly
|
||||
- Verify data transformations
|
||||
- Test edge cases and boundary conditions
|
||||
|
||||
### Mobile Tests (React Native)
|
||||
|
||||
#### `ExampleComponent.test.tsx`
|
||||
Demonstrates:
|
||||
- Component rendering
|
||||
- User interactions (press, long press)
|
||||
- State management
|
||||
- Props validation
|
||||
- Accessibility testing
|
||||
- Performance testing
|
||||
- Snapshot testing
|
||||
|
||||
**Key Patterns**:
|
||||
- Use `@testing-library/react-native`
|
||||
- Test user behavior, not implementation
|
||||
- Verify accessibility props
|
||||
- Test loading and error states
|
||||
|
||||
#### `authService.test.ts`
|
||||
Demonstrates:
|
||||
- Async service testing
|
||||
- API call mocking with fetch
|
||||
- Storage operations (SecureStore)
|
||||
- Error handling (network, storage)
|
||||
- Token management
|
||||
- Integration with other services
|
||||
|
||||
**Key Patterns**:
|
||||
- Mock global fetch
|
||||
- Mock Expo modules (SecureStore)
|
||||
- Test timeout scenarios
|
||||
- Verify storage operations
|
||||
|
||||
### Web Tests (SvelteKit)
|
||||
|
||||
#### `Button.test.ts`
|
||||
Demonstrates:
|
||||
- Svelte 5 component testing
|
||||
- Reactive state with runes ($state, $derived)
|
||||
- User events
|
||||
- Accessibility
|
||||
- Variants and sizes
|
||||
- Custom events
|
||||
- Debouncing
|
||||
|
||||
**Key Patterns**:
|
||||
- Use `@testing-library/svelte`
|
||||
- Test Svelte 5 reactivity
|
||||
- Verify accessibility attributes
|
||||
- Test custom event dispatch
|
||||
|
||||
#### `page.server.test.ts`
|
||||
Demonstrates:
|
||||
- Server load function testing
|
||||
- Form action testing
|
||||
- Database mocking (PocketBase)
|
||||
- Authentication checks
|
||||
- Input validation and sanitization
|
||||
- Authorization enforcement
|
||||
- File upload handling
|
||||
|
||||
**Key Patterns**:
|
||||
- Mock `locals` object
|
||||
- Mock database client
|
||||
- Test redirect behavior
|
||||
- Verify authorization logic
|
||||
- Sanitize user input
|
||||
|
||||
### Shared Package Tests
|
||||
|
||||
#### `format.test.ts`
|
||||
Demonstrates:
|
||||
- Pure function testing
|
||||
- Parameterized tests (it.each)
|
||||
- Edge case testing
|
||||
- Boundary testing
|
||||
- Property-based testing
|
||||
- Security testing (XSS, SQL injection)
|
||||
- Unicode and emoji handling
|
||||
|
||||
**Key Patterns**:
|
||||
- Test with multiple inputs using `it.each`
|
||||
- Cover edge cases thoroughly
|
||||
- Test security vulnerabilities
|
||||
- Verify type safety
|
||||
|
||||
## How to Use These Examples
|
||||
|
||||
### 1. Copy and Adapt
|
||||
|
||||
Copy the relevant example to your project and adapt it:
|
||||
|
||||
```bash
|
||||
# Copy backend controller test
|
||||
cp docs/test-examples/backend/example.controller.spec.ts \
|
||||
apps/YOUR_PROJECT/apps/backend/src/your-module/__tests__/your.controller.spec.ts
|
||||
|
||||
# Update imports and names
|
||||
```
|
||||
|
||||
### 2. Follow the Patterns
|
||||
|
||||
Each example demonstrates specific testing patterns:
|
||||
|
||||
- **AAA Pattern**: Arrange, Act, Assert
|
||||
- **Descriptive Names**: Clear test descriptions
|
||||
- **Mock Management**: Proper setup and cleanup
|
||||
- **Error Testing**: Both happy and error paths
|
||||
- **Edge Cases**: Boundary conditions and special cases
|
||||
|
||||
### 3. Customize for Your Needs
|
||||
|
||||
Adapt the examples to your specific requirements:
|
||||
|
||||
```typescript
|
||||
// Example: Add project-specific mocks
|
||||
jest.mock('@your-project/custom-service', () => ({
|
||||
CustomService: {
|
||||
doSomething: jest.fn(),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### 4. Reference Best Practices
|
||||
|
||||
Each file includes comments explaining:
|
||||
- Why specific patterns are used
|
||||
- What to test and what not to test
|
||||
- Common pitfalls to avoid
|
||||
- Performance considerations
|
||||
|
||||
## Testing Principles Demonstrated
|
||||
|
||||
### 1. Test Behavior, Not Implementation
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Testing behavior
|
||||
it('should display error message when login fails', async () => {
|
||||
await userEvent.click(loginButton);
|
||||
expect(screen.getByText('Invalid credentials')).toBeVisible();
|
||||
});
|
||||
|
||||
// ❌ Bad - Testing implementation
|
||||
it('should set isLoading to false after login', async () => {
|
||||
await userEvent.click(loginButton);
|
||||
expect(component.state.isLoading).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Isolation
|
||||
|
||||
Each test should be independent:
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); // Clear mock call history
|
||||
// Reset any state
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Comprehensive Coverage
|
||||
|
||||
Cover all code paths:
|
||||
|
||||
```typescript
|
||||
describe('createItem', () => {
|
||||
it('should create successfully'); // Happy path
|
||||
it('should handle validation errors'); // Error path
|
||||
it('should handle database errors'); // Error path
|
||||
it('should handle edge cases'); // Edge cases
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Readable Tests
|
||||
|
||||
Make tests self-documenting:
|
||||
|
||||
```typescript
|
||||
describe('User Authentication', () => {
|
||||
describe('signIn', () => {
|
||||
it('should sign in successfully with valid credentials', () => {
|
||||
// Test implementation
|
||||
});
|
||||
|
||||
it('should reject invalid email format', () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Common Test Scenarios
|
||||
|
||||
### Authentication Testing
|
||||
|
||||
```typescript
|
||||
it('should require authentication', async () => {
|
||||
mockEvent.locals = { user: null };
|
||||
await expect(load(mockEvent)).rejects.toThrow('Redirect');
|
||||
});
|
||||
|
||||
it('should allow access with valid token', async () => {
|
||||
mockEvent.locals = { user: { id: '123' } };
|
||||
const result = await load(mockEvent);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```typescript
|
||||
it('should validate required fields', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('title', ''); // Invalid
|
||||
|
||||
const result = await actions.create(mockEvent);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('required');
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
it('should handle network errors gracefully', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await authService.signIn('test@example.com', 'password');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Network');
|
||||
});
|
||||
```
|
||||
|
||||
### Async Operations
|
||||
|
||||
```typescript
|
||||
it('should wait for async operation to complete', async () => {
|
||||
const promise = service.fetchData();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(service.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
When writing tests, ensure you cover:
|
||||
|
||||
- [ ] Happy path (successful execution)
|
||||
- [ ] Error paths (validation errors, API errors)
|
||||
- [ ] Edge cases (empty inputs, null values, boundaries)
|
||||
- [ ] Authentication/authorization
|
||||
- [ ] Input sanitization
|
||||
- [ ] Accessibility (for components)
|
||||
- [ ] Loading states
|
||||
- [ ] Error states
|
||||
- [ ] Network failures (for API calls)
|
||||
- [ ] Storage failures (for persistence)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Full Testing Strategy](../TESTING.md)
|
||||
- [Implementation Guide](../TESTING_IMPLEMENTATION_GUIDE.md)
|
||||
- [Shared Test Configurations](../../packages/test-config/)
|
||||
- [Jest Documentation](https://jestjs.io/)
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Testing Library](https://testing-library.com/)
|
||||
- [Playwright](https://playwright.dev/)
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new examples:
|
||||
|
||||
1. Follow existing naming conventions
|
||||
2. Add comprehensive comments
|
||||
3. Demonstrate best practices
|
||||
4. Cover edge cases
|
||||
5. Update this README
|
||||
|
||||
## Questions?
|
||||
|
||||
- Check the [Testing Strategy](../TESTING.md) for overall approach
|
||||
- Review [Implementation Guide](../TESTING_IMPLEMENTATION_GUIDE.md) for step-by-step instructions
|
||||
- Look at existing tests in the project for patterns
|
||||
- Ask in team chat for project-specific guidance
|
||||
251
docs/test-examples/backend/example.controller.spec.ts
Normal file
251
docs/test-examples/backend/example.controller.spec.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Example NestJS Controller Test
|
||||
*
|
||||
* This demonstrates best practices for testing NestJS controllers:
|
||||
* - Mock all dependencies
|
||||
* - Test successful responses
|
||||
* - Test error handling
|
||||
* - Test authentication/authorization
|
||||
* - Test validation
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
||||
import { ExampleController } from '../example.controller';
|
||||
import { ExampleService } from '../example.service';
|
||||
import { CreateExampleDto } from '../dto/create-example.dto';
|
||||
import { UpdateExampleDto } from '../dto/update-example.dto';
|
||||
|
||||
describe('ExampleController', () => {
|
||||
let controller: ExampleController;
|
||||
let service: ExampleService;
|
||||
|
||||
// Mock data
|
||||
const mockUser = { sub: 'user-123', email: 'test@example.com' };
|
||||
const mockExample = {
|
||||
id: 'example-123',
|
||||
title: 'Test Example',
|
||||
description: 'Test description',
|
||||
userId: 'user-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ExampleController],
|
||||
providers: [
|
||||
{
|
||||
provide: ExampleService,
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ExampleController>(ExampleController);
|
||||
service = module.get<ExampleService>(ExampleService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto: CreateExampleDto = {
|
||||
title: 'New Example',
|
||||
description: 'New description',
|
||||
};
|
||||
|
||||
it('should create an example successfully', async () => {
|
||||
const expectedResult = {
|
||||
data: { ...mockExample, ...createDto },
|
||||
error: null,
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'create').mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.create(createDto, { user: mockUser });
|
||||
|
||||
expect(result).toEqual(expectedResult.data);
|
||||
expect(service.create).toHaveBeenCalledWith(createDto, mockUser.sub);
|
||||
expect(service.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for invalid data', async () => {
|
||||
const invalidDto = { title: '', description: 'Test' } as CreateExampleDto;
|
||||
|
||||
jest.spyOn(service, 'create').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Validation failed'),
|
||||
});
|
||||
|
||||
await expect(controller.create(invalidDto, { user: mockUser })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when user is not authenticated', async () => {
|
||||
await expect(controller.create(createDto, { user: null })).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should handle service errors gracefully', async () => {
|
||||
jest.spyOn(service, 'create').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Database error'),
|
||||
});
|
||||
|
||||
await expect(controller.create(createDto, { user: mockUser })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all examples for the user', async () => {
|
||||
const expectedResult = {
|
||||
data: [mockExample],
|
||||
error: null,
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'findAll').mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.findAll({ user: mockUser });
|
||||
|
||||
expect(result).toEqual(expectedResult.data);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockUser.sub);
|
||||
expect(service.findAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no examples', async () => {
|
||||
jest.spyOn(service, 'findAll').mockResolvedValue({
|
||||
data: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await controller.findAll({ user: mockUser });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await expect(controller.findAll({ user: null })).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
const exampleId = 'example-123';
|
||||
|
||||
it('should return a single example', async () => {
|
||||
jest.spyOn(service, 'findOne').mockResolvedValue({
|
||||
data: mockExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await controller.findOne(exampleId, { user: mockUser });
|
||||
|
||||
expect(result).toEqual(mockExample);
|
||||
expect(service.findOne).toHaveBeenCalledWith(exampleId, mockUser.sub);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when example does not exist', async () => {
|
||||
jest.spyOn(service, 'findOne').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Not found'),
|
||||
});
|
||||
|
||||
await expect(controller.findOne('invalid-id', { user: mockUser })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should not allow access to other users examples', async () => {
|
||||
const otherUserExample = { ...mockExample, userId: 'other-user' };
|
||||
|
||||
jest.spyOn(service, 'findOne').mockResolvedValue({
|
||||
data: otherUserExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await expect(controller.findOne(exampleId, { user: mockUser })).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const exampleId = 'example-123';
|
||||
const updateDto: UpdateExampleDto = {
|
||||
title: 'Updated Title',
|
||||
};
|
||||
|
||||
it('should update an example successfully', async () => {
|
||||
const updatedExample = { ...mockExample, ...updateDto };
|
||||
|
||||
jest.spyOn(service, 'update').mockResolvedValue({
|
||||
data: updatedExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await controller.update(exampleId, updateDto, { user: mockUser });
|
||||
|
||||
expect(result).toEqual(updatedExample);
|
||||
expect(service.update).toHaveBeenCalledWith(exampleId, updateDto, mockUser.sub);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when example does not exist', async () => {
|
||||
jest.spyOn(service, 'update').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Not found'),
|
||||
});
|
||||
|
||||
await expect(controller.update('invalid-id', updateDto, { user: mockUser })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate update data', async () => {
|
||||
const invalidDto = { title: '' } as UpdateExampleDto;
|
||||
|
||||
jest.spyOn(service, 'update').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Validation failed'),
|
||||
});
|
||||
|
||||
await expect(controller.update(exampleId, invalidDto, { user: mockUser })).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
const exampleId = 'example-123';
|
||||
|
||||
it('should delete an example successfully', async () => {
|
||||
jest.spyOn(service, 'remove').mockResolvedValue({
|
||||
data: { success: true },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await controller.remove(exampleId, { user: mockUser });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(service.remove).toHaveBeenCalledWith(exampleId, mockUser.sub);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when example does not exist', async () => {
|
||||
jest.spyOn(service, 'remove').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Not found'),
|
||||
});
|
||||
|
||||
await expect(controller.remove('invalid-id', { user: mockUser })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should not allow deletion of other users examples', async () => {
|
||||
jest.spyOn(service, 'remove').mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Unauthorized'),
|
||||
});
|
||||
|
||||
await expect(controller.remove(exampleId, { user: mockUser })).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
379
docs/test-examples/backend/example.service.spec.ts
Normal file
379
docs/test-examples/backend/example.service.spec.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Example NestJS Service Test
|
||||
*
|
||||
* This demonstrates best practices for testing NestJS services:
|
||||
* - Mock database/external dependencies
|
||||
* - Test business logic thoroughly
|
||||
* - Test error handling
|
||||
* - Test edge cases
|
||||
* - Use Result pattern for error handling
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExampleService } from '../example.service';
|
||||
import { SupabaseDataService } from '../../core/services/supabase-data.service';
|
||||
import { ExternalApiService } from '../../core/services/external-api.service';
|
||||
import { CreateExampleDto } from '../dto/create-example.dto';
|
||||
|
||||
describe('ExampleService', () => {
|
||||
let service: ExampleService;
|
||||
let supabaseService: jest.Mocked<SupabaseDataService>;
|
||||
let externalApiService: jest.Mocked<ExternalApiService>;
|
||||
|
||||
const mockUser = { sub: 'user-123', email: 'test@example.com' };
|
||||
const mockExample = {
|
||||
id: 'example-123',
|
||||
title: 'Test Example',
|
||||
description: 'Test description',
|
||||
userId: 'user-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mocked services
|
||||
const mockSupabaseService = {
|
||||
insertExample: jest.fn(),
|
||||
getExample: jest.fn(),
|
||||
getExamplesByUser: jest.fn(),
|
||||
updateExample: jest.fn(),
|
||||
deleteExample: jest.fn(),
|
||||
};
|
||||
|
||||
const mockExternalApiService = {
|
||||
enrichExample: jest.fn(),
|
||||
validateExample: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ExampleService,
|
||||
{
|
||||
provide: SupabaseDataService,
|
||||
useValue: mockSupabaseService,
|
||||
},
|
||||
{
|
||||
provide: ExternalApiService,
|
||||
useValue: mockExternalApiService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ExampleService>(ExampleService);
|
||||
supabaseService = module.get(SupabaseDataService) as jest.Mocked<SupabaseDataService>;
|
||||
externalApiService = module.get(ExternalApiService) as jest.Mocked<ExternalApiService>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto: CreateExampleDto = {
|
||||
title: 'New Example',
|
||||
description: 'New description',
|
||||
};
|
||||
|
||||
it('should create an example successfully', async () => {
|
||||
// Arrange
|
||||
const enrichedData = {
|
||||
...createDto,
|
||||
metadata: { enhanced: true },
|
||||
};
|
||||
|
||||
externalApiService.enrichExample.mockResolvedValue({
|
||||
data: enrichedData,
|
||||
error: null,
|
||||
});
|
||||
|
||||
supabaseService.insertExample.mockResolvedValue({
|
||||
data: { ...mockExample, ...enrichedData },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.create(createDto, mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data.title).toBe(createDto.title);
|
||||
expect(externalApiService.enrichExample).toHaveBeenCalledWith(createDto);
|
||||
expect(supabaseService.insertExample).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...enrichedData,
|
||||
userId: mockUser.sub,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle enrichment failure gracefully', async () => {
|
||||
// Arrange
|
||||
externalApiService.enrichExample.mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('API unavailable'),
|
||||
});
|
||||
|
||||
supabaseService.insertExample.mockResolvedValue({
|
||||
data: { ...mockExample, ...createDto },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.create(createDto, mockUser.sub);
|
||||
|
||||
// Assert - Should still create without enrichment
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toBeDefined();
|
||||
expect(supabaseService.insertExample).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...createDto,
|
||||
userId: mockUser.sub,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when database insert fails', async () => {
|
||||
// Arrange
|
||||
externalApiService.enrichExample.mockResolvedValue({
|
||||
data: createDto,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const dbError = new Error('Database connection failed');
|
||||
supabaseService.insertExample.mockResolvedValue({
|
||||
data: null,
|
||||
error: dbError,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.create(createDto, mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error.message).toContain('Database connection failed');
|
||||
});
|
||||
|
||||
it('should validate title is not empty', async () => {
|
||||
// Arrange
|
||||
const invalidDto = { ...createDto, title: '' };
|
||||
|
||||
// Act
|
||||
const result = await service.create(invalidDto, mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error.message).toContain('Title cannot be empty');
|
||||
expect(externalApiService.enrichExample).not.toHaveBeenCalled();
|
||||
expect(supabaseService.insertExample).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sanitize user input', async () => {
|
||||
// Arrange
|
||||
const maliciousDto = {
|
||||
title: '<script>alert("xss")</script>',
|
||||
description: 'Normal description',
|
||||
};
|
||||
|
||||
externalApiService.enrichExample.mockResolvedValue({
|
||||
data: maliciousDto,
|
||||
error: null,
|
||||
});
|
||||
|
||||
supabaseService.insertExample.mockResolvedValue({
|
||||
data: { ...mockExample, title: 'alert("xss")' }, // Sanitized
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.create(maliciousDto, mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.data.title).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all examples for a user', async () => {
|
||||
// Arrange
|
||||
const examples = [mockExample, { ...mockExample, id: 'example-456' }];
|
||||
|
||||
supabaseService.getExamplesByUser.mockResolvedValue({
|
||||
data: examples,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.findAll(mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(supabaseService.getExamplesByUser).toHaveBeenCalledWith(mockUser.sub);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no examples', async () => {
|
||||
// Arrange
|
||||
supabaseService.getExamplesByUser.mockResolvedValue({
|
||||
data: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.findAll(mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
// Arrange
|
||||
const dbError = new Error('Query timeout');
|
||||
supabaseService.getExamplesByUser.mockResolvedValue({
|
||||
data: null,
|
||||
error: dbError,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.findAll(mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a single example', async () => {
|
||||
// Arrange
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: mockExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.findOne('example-123', mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual(mockExample);
|
||||
expect(supabaseService.getExample).toHaveBeenCalledWith('example-123');
|
||||
});
|
||||
|
||||
it('should return error when example not found', async () => {
|
||||
// Arrange
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Not found'),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.findOne('invalid-id', mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.data).toBeNull();
|
||||
});
|
||||
|
||||
it('should verify user owns the example', async () => {
|
||||
// Arrange
|
||||
const otherUserExample = { ...mockExample, userId: 'other-user' };
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: otherUserExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.findOne('example-123', mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error.message).toContain('Unauthorized');
|
||||
expect(result.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an example successfully', async () => {
|
||||
// Arrange
|
||||
const updateDto = { title: 'Updated Title' };
|
||||
const updatedExample = { ...mockExample, ...updateDto };
|
||||
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: mockExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
supabaseService.updateExample.mockResolvedValue({
|
||||
data: updatedExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.update('example-123', updateDto, mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data.title).toBe('Updated Title');
|
||||
expect(supabaseService.updateExample).toHaveBeenCalledWith('example-123', updateDto);
|
||||
});
|
||||
|
||||
it('should not allow updating other users examples', async () => {
|
||||
// Arrange
|
||||
const otherUserExample = { ...mockExample, userId: 'other-user' };
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: otherUserExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.update('example-123', { title: 'New' }, mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error.message).toContain('Unauthorized');
|
||||
expect(supabaseService.updateExample).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete an example successfully', async () => {
|
||||
// Arrange
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: mockExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
supabaseService.deleteExample.mockResolvedValue({
|
||||
data: { success: true },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.remove('example-123', mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toEqual({ success: true });
|
||||
expect(supabaseService.deleteExample).toHaveBeenCalledWith('example-123');
|
||||
});
|
||||
|
||||
it('should not allow deleting other users examples', async () => {
|
||||
// Arrange
|
||||
const otherUserExample = { ...mockExample, userId: 'other-user' };
|
||||
supabaseService.getExample.mockResolvedValue({
|
||||
data: otherUserExample,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.remove('example-123', mockUser.sub);
|
||||
|
||||
// Assert
|
||||
expect(result.error).toBeDefined();
|
||||
expect(supabaseService.deleteExample).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
308
docs/test-examples/mobile/ExampleComponent.test.tsx
Normal file
308
docs/test-examples/mobile/ExampleComponent.test.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* Example React Native Component Test
|
||||
*
|
||||
* This demonstrates best practices for testing React Native components:
|
||||
* - Render testing
|
||||
* - User interaction testing
|
||||
* - State changes
|
||||
* - Props validation
|
||||
* - Accessibility testing
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react-native';
|
||||
import { ExampleComponent } from '../ExampleComponent';
|
||||
|
||||
describe('ExampleComponent', () => {
|
||||
// Mock data
|
||||
const mockOnPress = jest.fn();
|
||||
const mockOnLongPress = jest.fn();
|
||||
const defaultProps = {
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
onPress: mockOnPress,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render with required props', () => {
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} />);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('Test Description')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with testID for automation', () => {
|
||||
const { getByTestId } = render(<ExampleComponent {...defaultProps} testID="example-component" />);
|
||||
|
||||
expect(getByTestId('example-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
const { getByTestId, queryByText } = render(<ExampleComponent {...defaultProps} loading />);
|
||||
|
||||
expect(getByTestId('loading-indicator')).toBeTruthy();
|
||||
expect(queryByText('Test Title')).toBeNull(); // Content hidden when loading
|
||||
});
|
||||
|
||||
it('should render error state', () => {
|
||||
const errorMessage = 'Something went wrong';
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} error={errorMessage} />);
|
||||
|
||||
expect(getByText(errorMessage)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render optional icon when provided', () => {
|
||||
const { getByTestId } = render(<ExampleComponent {...defaultProps} icon="star" />);
|
||||
|
||||
expect(getByTestId('icon-star')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render description when not provided', () => {
|
||||
const { queryByText } = render(<ExampleComponent title="Title Only" onPress={mockOnPress} />);
|
||||
|
||||
expect(queryByText('Test Description')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onPress when pressed', () => {
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} />);
|
||||
|
||||
fireEvent.press(getByText('Test Title'));
|
||||
|
||||
expect(mockOnPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onLongPress when long pressed', () => {
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} onLongPress={mockOnLongPress} />);
|
||||
|
||||
fireEvent(getByText('Test Title'), 'onLongPress');
|
||||
|
||||
expect(mockOnLongPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call onPress when disabled', () => {
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} disabled />);
|
||||
|
||||
fireEvent.press(getByText('Test Title'));
|
||||
|
||||
expect(mockOnPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onPress when loading', () => {
|
||||
const { getByTestId } = render(
|
||||
<ExampleComponent {...defaultProps} loading testID="example-component" />
|
||||
);
|
||||
|
||||
fireEvent.press(getByTestId('example-component'));
|
||||
|
||||
expect(mockOnPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show feedback on press (opacity change)', async () => {
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} />);
|
||||
const touchable = getByText('Test Title').parent;
|
||||
|
||||
fireEvent(touchable, 'onPressIn');
|
||||
await waitFor(() => {
|
||||
expect(touchable.props.style).toMatchObject({
|
||||
opacity: 0.6, // Active opacity
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent(touchable, 'onPressOut');
|
||||
await waitFor(() => {
|
||||
expect(touchable.props.style).toMatchObject({
|
||||
opacity: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should toggle favorite state on icon press', async () => {
|
||||
const { getByTestId, rerender } = render(<ExampleComponent {...defaultProps} favoritable />);
|
||||
|
||||
const favoriteIcon = getByTestId('favorite-icon');
|
||||
expect(favoriteIcon.props.name).toBe('heart-outline'); // Initial state
|
||||
|
||||
fireEvent.press(favoriteIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(favoriteIcon.props.name).toBe('heart'); // Toggled state
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain expanded state across re-renders', async () => {
|
||||
const { getByTestId, rerender } = render(<ExampleComponent {...defaultProps} expandable />);
|
||||
|
||||
const expandButton = getByTestId('expand-button');
|
||||
fireEvent.press(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('expanded-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Re-render with updated props
|
||||
rerender(<ExampleComponent {...defaultProps} description="Updated Description" expandable />);
|
||||
|
||||
// Expanded state should persist
|
||||
expect(getByTestId('expanded-content')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props Validation', () => {
|
||||
it('should handle empty title gracefully', () => {
|
||||
const { queryByText } = render(<ExampleComponent title="" onPress={mockOnPress} />);
|
||||
|
||||
expect(queryByText('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should truncate long titles', () => {
|
||||
const longTitle = 'This is a very long title that should be truncated at some point';
|
||||
const { getByText } = render(<ExampleComponent title={longTitle} onPress={mockOnPress} />);
|
||||
|
||||
const titleElement = getByText(/This is a very long/);
|
||||
expect(titleElement.props.numberOfLines).toBe(1);
|
||||
expect(titleElement.props.ellipsizeMode).toBe('tail');
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { backgroundColor: 'red', padding: 20 };
|
||||
const { getByTestId } = render(
|
||||
<ExampleComponent {...defaultProps} style={customStyle} testID="example-component" />
|
||||
);
|
||||
|
||||
const component = getByTestId('example-component');
|
||||
expect(component.props.style).toMatchObject(customStyle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible label', () => {
|
||||
const { getByLabelText } = render(<ExampleComponent {...defaultProps} />);
|
||||
|
||||
expect(getByLabelText('Test Title')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have accessible role', () => {
|
||||
const { getByRole } = render(<ExampleComponent {...defaultProps} />);
|
||||
|
||||
expect(getByRole('button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have accessible hint', () => {
|
||||
const { getByA11yHint } = render(
|
||||
<ExampleComponent {...defaultProps} accessibilityHint="Double tap to open details" />
|
||||
);
|
||||
|
||||
expect(getByA11yHint('Double tap to open details')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be disabled for screen readers when disabled', () => {
|
||||
const { getByTestId } = render(
|
||||
<ExampleComponent {...defaultProps} disabled testID="example-component" />
|
||||
);
|
||||
|
||||
const component = getByTestId('example-component');
|
||||
expect(component.props.accessibilityState).toMatchObject({
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid taps (debouncing)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { getByText } = render(<ExampleComponent {...defaultProps} />);
|
||||
|
||||
const button = getByText('Test Title');
|
||||
|
||||
// Rapid taps
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
// Should only call once due to debouncing
|
||||
expect(mockOnPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle null children gracefully', () => {
|
||||
const { container } = render(<ExampleComponent {...defaultProps}>{null}</ExampleComponent>);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle undefined props gracefully', () => {
|
||||
const { getByText } = render(<ExampleComponent title="Test" onPress={mockOnPress} description={undefined} />);
|
||||
|
||||
expect(getByText('Test')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should not re-render unnecessarily', () => {
|
||||
const renderSpy = jest.fn();
|
||||
const ComponentWithSpy = (props) => {
|
||||
renderSpy();
|
||||
return <ExampleComponent {...props} />;
|
||||
};
|
||||
|
||||
const { rerender } = render(<ComponentWithSpy {...defaultProps} />);
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<ComponentWithSpy {...defaultProps} />);
|
||||
|
||||
// Should use memo and not re-render
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should only re-render when relevant props change', () => {
|
||||
const renderSpy = jest.fn();
|
||||
const ComponentWithSpy = (props) => {
|
||||
renderSpy();
|
||||
return <ExampleComponent {...props} />;
|
||||
};
|
||||
|
||||
const { rerender } = render(<ComponentWithSpy {...defaultProps} />);
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-render with different title
|
||||
rerender(<ComponentWithSpy {...defaultProps} title="New Title" />);
|
||||
|
||||
// Should re-render
|
||||
expect(renderSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshot Testing', () => {
|
||||
it('should match snapshot for default state', () => {
|
||||
const { toJSON } = render(<ExampleComponent {...defaultProps} />);
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot for loading state', () => {
|
||||
const { toJSON } = render(<ExampleComponent {...defaultProps} loading />);
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot for error state', () => {
|
||||
const { toJSON } = render(<ExampleComponent {...defaultProps} error="Error message" />);
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
342
docs/test-examples/mobile/authService.test.ts
Normal file
342
docs/test-examples/mobile/authService.test.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Example React Native Service Test
|
||||
*
|
||||
* This demonstrates best practices for testing services:
|
||||
* - Mock fetch/API calls
|
||||
* - Test async operations
|
||||
* - Test error handling
|
||||
* - Test storage operations
|
||||
* - Use MSW for API mocking (optional)
|
||||
*/
|
||||
|
||||
import { authService } from '../authService';
|
||||
import { tokenManager } from '../tokenManager';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('expo-secure-store');
|
||||
jest.mock('../tokenManager');
|
||||
|
||||
// Mock data
|
||||
const mockTokens = {
|
||||
appToken: 'mock-app-token-12345',
|
||||
refreshToken: 'mock-refresh-token-12345',
|
||||
manaToken: 'mock-mana-token-12345',
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('signIn', () => {
|
||||
it('should sign in successfully with valid credentials', async () => {
|
||||
// Arrange
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
...mockTokens,
|
||||
user: mockUser,
|
||||
}),
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await authService.signIn('test@example.com', 'password123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual(mockUser);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/auth/signin'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.stringContaining('test@example.com'),
|
||||
})
|
||||
);
|
||||
|
||||
// Verify tokens were stored
|
||||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('@auth/appToken', mockTokens.appToken);
|
||||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('@auth/refreshToken', mockTokens.refreshToken);
|
||||
});
|
||||
|
||||
it('should handle invalid credentials error', async () => {
|
||||
// Arrange
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
}),
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
// Act
|
||||
const result = await authService.signIn('test@example.com', 'wrongpassword');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('INVALID_CREDENTIALS');
|
||||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
// Arrange
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network request failed'));
|
||||
|
||||
// Act
|
||||
const result = await authService.signIn('test@example.com', 'password123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Network');
|
||||
});
|
||||
|
||||
it('should handle storage errors', async () => {
|
||||
// Arrange
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
...mockTokens,
|
||||
user: mockUser,
|
||||
}),
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(SecureStore.setItemAsync as jest.Mock).mockRejectedValue(new Error('Storage unavailable'));
|
||||
|
||||
// Act
|
||||
const result = await authService.signIn('test@example.com', 'password123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Storage');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
// Act
|
||||
const result = await authService.signIn('invalid-email', 'password123');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('email');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate password is not empty', async () => {
|
||||
// Act
|
||||
const result = await authService.signIn('test@example.com', '');
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('password');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle timeout errors', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Arrange
|
||||
(global.fetch as jest.Mock).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ ok: true, json: async () => ({}) }), 60000);
|
||||
})
|
||||
);
|
||||
|
||||
// Act
|
||||
const resultPromise = authService.signIn('test@example.com', 'password123');
|
||||
|
||||
jest.advanceTimersByTime(30000); // Advance 30s (timeout threshold)
|
||||
const result = await resultPromise;
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timeout');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signOut', () => {
|
||||
it('should sign out successfully', async () => {
|
||||
// Arrange
|
||||
(SecureStore.deleteItemAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
(tokenManager.clearTokens as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await authService.signOut();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('@auth/appToken');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('@auth/refreshToken');
|
||||
expect(tokenManager.clearTokens).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle storage errors during sign out', async () => {
|
||||
// Arrange
|
||||
(SecureStore.deleteItemAsync as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
||||
|
||||
// Act
|
||||
const result = await authService.signOut();
|
||||
|
||||
// Assert
|
||||
// Should succeed even if storage fails (user intent matters)
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('should refresh token successfully', async () => {
|
||||
// Arrange
|
||||
const oldRefreshToken = 'old-refresh-token';
|
||||
const newTokens = {
|
||||
appToken: 'new-app-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
};
|
||||
|
||||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(oldRefreshToken);
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
...newTokens,
|
||||
}),
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await authService.refreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.appToken).toBe(newTokens.appToken);
|
||||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('@auth/appToken', newTokens.appToken);
|
||||
});
|
||||
|
||||
it('should handle missing refresh token', async () => {
|
||||
// Arrange
|
||||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await authService.refreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No refresh token');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle expired refresh token', async () => {
|
||||
// Arrange
|
||||
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue('expired-refresh-token');
|
||||
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: 'REFRESH_TOKEN_EXPIRED',
|
||||
}),
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
// Act
|
||||
const result = await authService.refreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('REFRESH_TOKEN_EXPIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('should return true when valid token exists', async () => {
|
||||
// Arrange
|
||||
(tokenManager.getValidToken as jest.Mock).mockResolvedValue('valid-token');
|
||||
|
||||
// Act
|
||||
const result = await authService.checkAuthStatus();
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no token exists', async () => {
|
||||
// Arrange
|
||||
(tokenManager.getValidToken as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await authService.checkAuthStatus();
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh expired token automatically', async () => {
|
||||
// Arrange
|
||||
(tokenManager.getValidToken as jest.Mock)
|
||||
.mockResolvedValueOnce(null) // First call: no valid token
|
||||
.mockResolvedValueOnce('new-valid-token'); // After refresh
|
||||
|
||||
(authService.refreshToken as jest.Mock) = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
appToken: 'new-valid-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authService.checkAuthStatus();
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(authService.refreshToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with TokenManager', () => {
|
||||
it('should notify TokenManager of new tokens', async () => {
|
||||
// Arrange
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
...mockTokens,
|
||||
user: mockUser,
|
||||
}),
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
(tokenManager.setTokens as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await authService.signIn('test@example.com', 'password123');
|
||||
|
||||
// Assert
|
||||
expect(tokenManager.setTokens).toHaveBeenCalledWith(mockTokens);
|
||||
});
|
||||
});
|
||||
});
|
||||
352
docs/test-examples/shared/format.test.ts
Normal file
352
docs/test-examples/shared/format.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* Example Shared Package Utility Test
|
||||
*
|
||||
* This demonstrates best practices for testing utility functions:
|
||||
* - Test pure functions
|
||||
* - Test edge cases
|
||||
* - Test error handling
|
||||
* - Parameterized tests
|
||||
* - Property-based testing (optional)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate, truncate, slugify, capitalize, debounce } from '../format';
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format date with default format', () => {
|
||||
const date = new Date('2024-01-15T12:00:00Z');
|
||||
const result = formatDate(date);
|
||||
|
||||
expect(result).toBe('2024-01-15');
|
||||
});
|
||||
|
||||
it('should format date with custom format', () => {
|
||||
const date = new Date('2024-01-15T12:00:00Z');
|
||||
const result = formatDate(date, 'MM/dd/yyyy');
|
||||
|
||||
expect(result).toBe('01/15/2024');
|
||||
});
|
||||
|
||||
it('should handle different locales', () => {
|
||||
const date = new Date('2024-01-15T12:00:00Z');
|
||||
const result = formatDate(date, 'PPP', { locale: 'de' });
|
||||
|
||||
expect(result).toContain('Januar');
|
||||
});
|
||||
|
||||
it('should handle invalid dates', () => {
|
||||
expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date');
|
||||
});
|
||||
|
||||
it('should handle null or undefined', () => {
|
||||
expect(() => formatDate(null as any)).toThrow('Invalid date');
|
||||
expect(() => formatDate(undefined as any)).toThrow('Invalid date');
|
||||
});
|
||||
|
||||
it('should handle dates at boundaries', () => {
|
||||
// Min safe date
|
||||
const minDate = new Date(-8640000000000000);
|
||||
expect(() => formatDate(minDate)).not.toThrow();
|
||||
|
||||
// Max safe date
|
||||
const maxDate = new Date(8640000000000000);
|
||||
expect(() => formatDate(maxDate)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle timezone differences', () => {
|
||||
const date = new Date('2024-01-15T00:00:00Z');
|
||||
const resultUTC = formatDate(date, 'yyyy-MM-dd HH:mm', { timeZone: 'UTC' });
|
||||
const resultEST = formatDate(date, 'yyyy-MM-dd HH:mm', { timeZone: 'America/New_York' });
|
||||
|
||||
expect(resultUTC).not.toBe(resultEST);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncate', () => {
|
||||
it('should truncate long strings', () => {
|
||||
const text = 'This is a very long string that should be truncated';
|
||||
const result = truncate(text, 20);
|
||||
|
||||
expect(result).toBe('This is a very long…');
|
||||
expect(result.length).toBeLessThanOrEqual(21); // 20 chars + ellipsis
|
||||
});
|
||||
|
||||
it('should not truncate short strings', () => {
|
||||
const text = 'Short';
|
||||
const result = truncate(text, 20);
|
||||
|
||||
expect(result).toBe('Short');
|
||||
});
|
||||
|
||||
it('should use custom ellipsis', () => {
|
||||
const text = 'This is a very long string';
|
||||
const result = truncate(text, 10, '...');
|
||||
|
||||
expect(result).toBe('This is...');
|
||||
});
|
||||
|
||||
it('should handle exact length match', () => {
|
||||
const text = 'Exactly20Characters!';
|
||||
const result = truncate(text, 20);
|
||||
|
||||
expect(result).toBe('Exactly20Characters!');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = truncate('', 10);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle length of 0', () => {
|
||||
const text = 'Some text';
|
||||
const result = truncate(text, 0);
|
||||
|
||||
expect(result).toBe('…');
|
||||
});
|
||||
|
||||
it('should handle negative length', () => {
|
||||
expect(() => truncate('text', -1)).toThrow('Length must be non-negative');
|
||||
});
|
||||
|
||||
it('should preserve word boundaries (optional feature)', () => {
|
||||
const text = 'This is a very long string';
|
||||
const result = truncate(text, 15, '…', { preserveWords: true });
|
||||
|
||||
expect(result).toBe('This is a very…');
|
||||
expect(result).not.toContain('very l'); // Should not break mid-word
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('should convert to lowercase', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should replace spaces with hyphens', () => {
|
||||
expect(slugify('multiple spaces')).toBe('multiple-spaces');
|
||||
});
|
||||
|
||||
it('should remove special characters', () => {
|
||||
expect(slugify('Hello & World!')).toBe('hello-world');
|
||||
expect(slugify('React@TypeScript#2024')).toBe('react-typescript-2024');
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
expect(slugify('Café résumé')).toBe('cafe-resume');
|
||||
expect(slugify('Zürich naïve')).toBe('zurich-naive');
|
||||
});
|
||||
|
||||
it('should remove leading and trailing hyphens', () => {
|
||||
expect(slugify(' hello world ')).toBe('hello-world');
|
||||
expect(slugify('!!!hello world!!!')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should handle already slugified strings', () => {
|
||||
expect(slugify('already-a-slug')).toBe('already-a-slug');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle strings with only special characters', () => {
|
||||
expect(slugify('!@#$%^&*()')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long strings', () => {
|
||||
const longString = 'a'.repeat(1000);
|
||||
const result = slugify(longString);
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(200); // Max slug length
|
||||
});
|
||||
|
||||
// Parameterized tests
|
||||
it.each([
|
||||
['Hello World', 'hello-world'],
|
||||
['React & TypeScript', 'react-typescript'],
|
||||
['2024 年', '2024'],
|
||||
[' Multiple Spaces ', 'multiple-spaces'],
|
||||
['CamelCaseText', 'camelcasetext'],
|
||||
])('slugify("%s") should return "%s"', (input, expected) => {
|
||||
expect(slugify(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capitalize', () => {
|
||||
it('should capitalize first letter', () => {
|
||||
expect(capitalize('hello')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should handle already capitalized strings', () => {
|
||||
expect(capitalize('Hello')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should handle single characters', () => {
|
||||
expect(capitalize('a')).toBe('A');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(capitalize('')).toBe('');
|
||||
});
|
||||
|
||||
it('should not affect rest of string', () => {
|
||||
expect(capitalize('hELLO wORLD')).toBe('HELLO wORLD');
|
||||
});
|
||||
|
||||
it('should handle strings starting with numbers', () => {
|
||||
expect(capitalize('123abc')).toBe('123abc');
|
||||
});
|
||||
|
||||
it('should handle strings with leading whitespace', () => {
|
||||
expect(capitalize(' hello')).toBe(' Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
it('should delay function execution', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = debounce(mockFn, 500);
|
||||
|
||||
debouncedFn();
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(mockFn).toHaveBeenCalledOnce();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should cancel previous calls', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = debounce(mockFn, 500);
|
||||
|
||||
debouncedFn('call1');
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
debouncedFn('call2');
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
debouncedFn('call3');
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Should only call once with last argument
|
||||
expect(mockFn).toHaveBeenCalledOnce();
|
||||
expect(mockFn).toHaveBeenCalledWith('call3');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should preserve this context', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const obj = {
|
||||
value: 42,
|
||||
method: function () {
|
||||
return this.value;
|
||||
},
|
||||
};
|
||||
|
||||
const debouncedMethod = debounce(obj.method, 100);
|
||||
const result = debouncedMethod.call(obj);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(result).toBe(42);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle immediate option', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = debounce(mockFn, 500, { immediate: true });
|
||||
|
||||
debouncedFn();
|
||||
expect(mockFn).toHaveBeenCalledOnce(); // Called immediately
|
||||
|
||||
debouncedFn();
|
||||
expect(mockFn).toHaveBeenCalledOnce(); // Still once (debounced)
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
debouncedFn();
|
||||
expect(mockFn).toHaveBeenCalledTimes(2); // Called again after wait
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// Property-based testing example (requires fast-check)
|
||||
describe('Property-based tests', () => {
|
||||
it('slugify should always return lowercase', () => {
|
||||
// Using property-based testing to generate random inputs
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const randomString = Math.random().toString(36) + Math.random().toString(36);
|
||||
const result = slugify(randomString);
|
||||
|
||||
expect(result).toBe(result.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('truncate should never exceed max length', () => {
|
||||
const testCases = [
|
||||
'short',
|
||||
'exactly twenty chars',
|
||||
'this is a very long string that needs truncation',
|
||||
'a'.repeat(1000),
|
||||
];
|
||||
|
||||
testCases.forEach((text) => {
|
||||
const maxLength = 20;
|
||||
const result = truncate(text, maxLength);
|
||||
|
||||
// Result should be <= maxLength + ellipsis length
|
||||
expect(result.length).toBeLessThanOrEqual(maxLength + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Edge cases and boundary testing
|
||||
describe('Edge Cases', () => {
|
||||
describe('Unicode and Emoji handling', () => {
|
||||
it('should handle emoji in truncate', () => {
|
||||
const text = 'Hello 👋 World 🌍';
|
||||
const result = truncate(text, 10);
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(11);
|
||||
});
|
||||
|
||||
it('should handle emoji in slugify', () => {
|
||||
const result = slugify('Hello 👋 World');
|
||||
|
||||
expect(result).toBe('hello-world');
|
||||
expect(result).not.toContain('👋');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security considerations', () => {
|
||||
it('should sanitize XSS in slugify', () => {
|
||||
const malicious = '<script>alert("xss")</script>';
|
||||
const result = slugify(malicious);
|
||||
|
||||
expect(result).not.toContain('<');
|
||||
expect(result).not.toContain('>');
|
||||
expect(result).not.toContain('script');
|
||||
});
|
||||
|
||||
it('should handle SQL injection patterns', () => {
|
||||
const sqlInjection = "'; DROP TABLE users; --";
|
||||
const result = slugify(sqlInjection);
|
||||
|
||||
expect(result).not.toContain("'");
|
||||
expect(result).not.toContain(';');
|
||||
expect(result).not.toContain('--');
|
||||
});
|
||||
});
|
||||
});
|
||||
355
docs/test-examples/web/Button.test.ts
Normal file
355
docs/test-examples/web/Button.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* Example Svelte 5 Component Test
|
||||
*
|
||||
* This demonstrates best practices for testing Svelte 5 components:
|
||||
* - Test component rendering with runes
|
||||
* - Test user interactions
|
||||
* - Test reactive state ($state, $derived, $effect)
|
||||
* - Test events
|
||||
* - Test props
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Button from '../Button.svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('Button (Svelte 5)', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render with text content', () => {
|
||||
render(Button, { props: { children: 'Click Me' } });
|
||||
|
||||
expect(screen.getByText('Click Me')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with variant classes', () => {
|
||||
const { container } = render(Button, {
|
||||
props: {
|
||||
variant: 'primary',
|
||||
children: 'Primary Button',
|
||||
},
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain('btn-primary');
|
||||
});
|
||||
|
||||
it('should render with custom class', () => {
|
||||
const { container } = render(Button, {
|
||||
props: {
|
||||
class: 'custom-class',
|
||||
children: 'Button',
|
||||
},
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain('custom-class');
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
loading: true,
|
||||
children: 'Submit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('loading-spinner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render disabled state', () => {
|
||||
const { container } = render(Button, {
|
||||
props: {
|
||||
disabled: true,
|
||||
children: 'Disabled',
|
||||
},
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onclick when clicked', async () => {
|
||||
const onclick = vi.fn();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick,
|
||||
children: 'Click Me',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Click Me'));
|
||||
|
||||
expect(onclick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not call onclick when disabled', async () => {
|
||||
const onclick = vi.fn();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick,
|
||||
disabled: true,
|
||||
children: 'Disabled',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Disabled'));
|
||||
|
||||
expect(onclick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onclick when loading', async () => {
|
||||
const onclick = vi.fn();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick,
|
||||
loading: true,
|
||||
children: 'Loading',
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
await user.click(button);
|
||||
|
||||
expect(onclick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle keyboard events', async () => {
|
||||
const onclick = vi.fn();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick,
|
||||
children: 'Press Enter',
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
button.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onclick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reactive State (Svelte 5 Runes)', () => {
|
||||
it('should react to prop changes', async () => {
|
||||
const { component, rerender } = render(Button, {
|
||||
props: {
|
||||
loading: false,
|
||||
children: 'Submit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('loading-spinner')).toBeNull();
|
||||
|
||||
// Update props
|
||||
await rerender({ loading: true });
|
||||
|
||||
expect(screen.getByTestId('loading-spinner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should derive styles based on variant', () => {
|
||||
const { container, rerender } = render(Button, {
|
||||
props: {
|
||||
variant: 'primary',
|
||||
children: 'Button',
|
||||
},
|
||||
});
|
||||
|
||||
let button = container.querySelector('button');
|
||||
expect(button?.className).toContain('btn-primary');
|
||||
|
||||
rerender({ variant: 'secondary' });
|
||||
|
||||
button = container.querySelector('button');
|
||||
expect(button?.className).toContain('btn-secondary');
|
||||
expect(button?.className).not.toContain('btn-primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have button role', () => {
|
||||
render(Button, { props: { children: 'Button' } });
|
||||
|
||||
expect(screen.getByRole('button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should support aria-label', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
'aria-label': 'Close dialog',
|
||||
children: 'X',
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Close dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate disabled state to screen readers', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
disabled: true,
|
||||
children: 'Disabled',
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('should indicate loading state to screen readers', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
loading: true,
|
||||
children: 'Loading',
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.getAttribute('aria-busy')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variants', () => {
|
||||
it.each([
|
||||
['primary', 'btn-primary'],
|
||||
['secondary', 'btn-secondary'],
|
||||
['danger', 'btn-danger'],
|
||||
['ghost', 'btn-ghost'],
|
||||
])('should render %s variant with %s class', (variant, expectedClass) => {
|
||||
const { container } = render(Button, {
|
||||
props: {
|
||||
variant,
|
||||
children: 'Button',
|
||||
},
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain(expectedClass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sizes', () => {
|
||||
it.each([
|
||||
['sm', 'btn-sm'],
|
||||
['md', 'btn-md'],
|
||||
['lg', 'btn-lg'],
|
||||
])('should render %s size with %s class', (size, expectedClass) => {
|
||||
const { container } = render(Button, {
|
||||
props: {
|
||||
size,
|
||||
children: 'Button',
|
||||
},
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain(expectedClass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid clicks (debouncing)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onclick = vi.fn();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick,
|
||||
debounce: 500,
|
||||
children: 'Click',
|
||||
},
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Rapid clicks
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
|
||||
// Should only call once
|
||||
expect(onclick).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for debounce
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Click again
|
||||
await user.click(button);
|
||||
|
||||
expect(onclick).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle async onclick handlers', async () => {
|
||||
const asyncOnclick = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick: asyncOnclick,
|
||||
children: 'Async Click',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Async Click'));
|
||||
|
||||
expect(asyncOnclick).toHaveBeenCalled();
|
||||
|
||||
// Wait for async handler to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(asyncOnclick).toHaveReturnedWith(expect.any(Promise));
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null children gracefully', () => {
|
||||
render(Button, { props: {} });
|
||||
|
||||
expect(screen.getByRole('button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slots', () => {
|
||||
it('should render icon slot', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
children: 'With Icon',
|
||||
},
|
||||
// Note: Testing slots in Vitest requires different approach
|
||||
// This is a simplified example
|
||||
});
|
||||
|
||||
expect(screen.getByText('With Icon')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
it('should dispatch custom event on click', async () => {
|
||||
const { component } = render(Button, {
|
||||
props: {
|
||||
children: 'Custom Event',
|
||||
},
|
||||
});
|
||||
|
||||
const customEventHandler = vi.fn();
|
||||
component.$on('customClick', customEventHandler);
|
||||
|
||||
await user.click(screen.getByText('Custom Event'));
|
||||
|
||||
expect(customEventHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
422
docs/test-examples/web/page.server.test.ts
Normal file
422
docs/test-examples/web/page.server.test.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/**
|
||||
* Example SvelteKit Server Load Function Test
|
||||
*
|
||||
* This demonstrates best practices for testing SvelteKit server functions:
|
||||
* - Test load functions
|
||||
* - Test form actions
|
||||
* - Mock database/API calls
|
||||
* - Test error handling
|
||||
* - Test redirects
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { load, actions } from '../+page.server';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('$lib/server/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
users: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@sveltejs/kit', async () => {
|
||||
const actual = await vi.importActual('@sveltejs/kit');
|
||||
return {
|
||||
...actual,
|
||||
redirect: vi.fn((status, location) => {
|
||||
throw new Error(`Redirect: ${status} ${location}`);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Dashboard Server Load Function', () => {
|
||||
let mockLocals: any;
|
||||
let mockEvent: Partial<RequestEvent>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLocals = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
pb: {
|
||||
collection: vi.fn(() => ({
|
||||
getList: vi.fn(),
|
||||
getOne: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
mockEvent = {
|
||||
locals: mockLocals,
|
||||
params: {},
|
||||
url: new URL('http://localhost:5173/dashboard'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('load function', () => {
|
||||
it('should load user data successfully', async () => {
|
||||
// Arrange
|
||||
const mockItems = [
|
||||
{ id: '1', title: 'Item 1', createdAt: new Date() },
|
||||
{ id: '2', title: 'Item 2', createdAt: new Date() },
|
||||
];
|
||||
|
||||
mockLocals.pb.collection().getList.mockResolvedValue({
|
||||
items: mockItems,
|
||||
totalItems: 2,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await load(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items).toEqual(mockItems);
|
||||
expect(mockLocals.pb.collection).toHaveBeenCalledWith('items');
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
// Arrange
|
||||
mockLocals.pb.collection().getList.mockResolvedValue({
|
||||
items: [],
|
||||
totalItems: 0,
|
||||
page: 1,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await load(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should redirect when user is not authenticated', async () => {
|
||||
// Arrange
|
||||
mockEvent.locals = { user: null };
|
||||
|
||||
// Act & Assert
|
||||
await expect(load(mockEvent as RequestEvent)).rejects.toThrow('Redirect: 302 /signin');
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
// Arrange
|
||||
mockLocals.pb.collection().getList.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
// Act & Assert
|
||||
await expect(load(mockEvent as RequestEvent)).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
|
||||
it('should filter items by user', async () => {
|
||||
// Arrange
|
||||
const mockItems = [{ id: '1', title: 'Item 1', userId: 'user-123' }];
|
||||
|
||||
mockLocals.pb.collection().getList.mockResolvedValue({
|
||||
items: mockItems,
|
||||
});
|
||||
|
||||
// Act
|
||||
await load(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(mockLocals.pb.collection().getList).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.objectContaining({
|
||||
filter: expect.stringContaining('user-123'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pagination parameters', async () => {
|
||||
// Arrange
|
||||
mockEvent.url = new URL('http://localhost:5173/dashboard?page=2');
|
||||
|
||||
mockLocals.pb.collection().getList.mockResolvedValue({
|
||||
items: [],
|
||||
page: 2,
|
||||
});
|
||||
|
||||
// Act
|
||||
await load(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(mockLocals.pb.collection().getList).toHaveBeenCalledWith(
|
||||
2, // page
|
||||
20, // perPage
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should load related data efficiently', async () => {
|
||||
// Arrange
|
||||
const mockItems = [{ id: '1', categoryId: 'cat-1' }];
|
||||
const mockCategories = [{ id: 'cat-1', name: 'Category 1' }];
|
||||
|
||||
mockLocals.pb.collection('items').getList.mockResolvedValue({ items: mockItems });
|
||||
mockLocals.pb.collection('categories').getList.mockResolvedValue({ items: mockCategories });
|
||||
|
||||
// Act
|
||||
const result = await load(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.items).toBeDefined();
|
||||
expect(result.categories).toBeDefined();
|
||||
// Should only make 2 DB calls (not N+1)
|
||||
expect(mockLocals.pb.collection).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('form actions', () => {
|
||||
describe('create', () => {
|
||||
it('should create item successfully', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('title', 'New Item');
|
||||
formData.append('description', 'Description');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
const mockCreatedItem = {
|
||||
id: 'item-123',
|
||||
title: 'New Item',
|
||||
description: 'Description',
|
||||
};
|
||||
|
||||
mockLocals.pb.collection().create.mockResolvedValue(mockCreatedItem);
|
||||
|
||||
// Act
|
||||
const result = await actions.create(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
item: mockCreatedItem,
|
||||
});
|
||||
expect(mockLocals.pb.collection().create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'New Item',
|
||||
userId: 'user-123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('title', ''); // Empty title
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
// Act
|
||||
const result = await actions.create(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining('Title is required'),
|
||||
});
|
||||
expect(mockLocals.pb.collection().create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sanitize input data', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('title', '<script>alert("xss")</script>');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().create.mockResolvedValue({
|
||||
id: '1',
|
||||
title: 'alert("xss")', // Sanitized
|
||||
});
|
||||
|
||||
// Act
|
||||
await actions.create(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(mockLocals.pb.collection().create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.not.stringContaining('<script>'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('title', 'Test');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().create.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Act
|
||||
const result = await actions.create(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file uploads', async () => {
|
||||
// Arrange
|
||||
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||
const formData = new FormData();
|
||||
formData.append('title', 'Image Post');
|
||||
formData.append('image', file);
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().create.mockResolvedValue({
|
||||
id: '1',
|
||||
title: 'Image Post',
|
||||
image: 'uploads/test.jpg',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await actions.create(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockLocals.pb.collection().create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: expect.any(File),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update item successfully', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('id', 'item-123');
|
||||
formData.append('title', 'Updated Title');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().getOne.mockResolvedValue({
|
||||
id: 'item-123',
|
||||
userId: 'user-123',
|
||||
});
|
||||
|
||||
mockLocals.pb.collection().update.mockResolvedValue({
|
||||
id: 'item-123',
|
||||
title: 'Updated Title',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await actions.update(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockLocals.pb.collection().update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow updating other users items', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('id', 'item-123');
|
||||
formData.append('title', 'Hacked');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().getOne.mockResolvedValue({
|
||||
id: 'item-123',
|
||||
userId: 'other-user', // Different user
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await actions.update(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Unauthorized');
|
||||
expect(mockLocals.pb.collection().update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete item successfully', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('id', 'item-123');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().getOne.mockResolvedValue({
|
||||
id: 'item-123',
|
||||
userId: 'user-123',
|
||||
});
|
||||
|
||||
mockLocals.pb.collection().delete.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
const result = await actions.delete(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockLocals.pb.collection().delete).toHaveBeenCalledWith('item-123');
|
||||
});
|
||||
|
||||
it('should not allow deleting other users items', async () => {
|
||||
// Arrange
|
||||
const formData = new FormData();
|
||||
formData.append('id', 'item-123');
|
||||
|
||||
mockEvent.request = {
|
||||
formData: async () => formData,
|
||||
} as Request;
|
||||
|
||||
mockLocals.pb.collection().getOne.mockResolvedValue({
|
||||
id: 'item-123',
|
||||
userId: 'other-user',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await actions.delete(mockEvent as RequestEvent);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(mockLocals.pb.collection().delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue