add tests
Some checks failed
Contract Testing / contract-tests (push) Failing after 6m7s
Contract Testing / contract-snapshot (push) Failing after 4m46s

This commit is contained in:
2026-01-22 11:52:42 +01:00
parent 40bc15ff61
commit fb1221701d
112 changed files with 30625 additions and 1059 deletions

View File

@@ -0,0 +1,620 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthPageService } from '@/lib/services/auth/AuthPageService';
import { AuthPageParams } from '@/lib/services/auth/AuthPageParams';
import { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
import { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
import { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
describe('AuthPageService', () => {
let service: AuthPageService;
beforeEach(() => {
service = new AuthPageService();
});
describe('processLoginParams', () => {
describe('happy paths', () => {
it('should process login params with returnTo', async () => {
const params: AuthPageParams = {
returnTo: '/dashboard',
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/dashboard');
expect(dto.hasInsufficientPermissions).toBe(true);
});
it('should process login params with null returnTo', async () => {
const params: AuthPageParams = {
returnTo: null,
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/dashboard');
expect(dto.hasInsufficientPermissions).toBe(false);
});
it('should process login params with undefined returnTo', async () => {
const params: AuthPageParams = {};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/dashboard');
expect(dto.hasInsufficientPermissions).toBe(false);
});
it('should process login params with empty string returnTo', async () => {
const params: AuthPageParams = {
returnTo: '',
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('');
expect(dto.hasInsufficientPermissions).toBe(true);
});
});
describe('decision branches', () => {
it('should handle different returnTo paths', async () => {
const paths = [
'/dashboard',
'/settings',
'/profile',
'/admin',
'/projects/123',
'/projects/123/tasks',
];
for (const path of paths) {
const params: AuthPageParams = {
returnTo: path,
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
expect(dto.hasInsufficientPermissions).toBe(true);
}
});
it('should handle special characters in returnTo path', async () => {
const paths = [
'/dashboard?param=value',
'/dashboard#section',
'/dashboard/with/slashes',
'/dashboard/with-dashes',
'/dashboard/with_underscores',
];
for (const path of paths) {
const params: AuthPageParams = {
returnTo: path,
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
expect(dto.hasInsufficientPermissions).toBe(true);
}
});
it('should handle different returnTo values and hasInsufficientPermissions', async () => {
const testCases = [
{ returnTo: '/dashboard', expectedHasInsufficientPermissions: true },
{ returnTo: null, expectedHasInsufficientPermissions: false },
{ returnTo: undefined, expectedHasInsufficientPermissions: false },
{ returnTo: '', expectedHasInsufficientPermissions: true },
];
for (const testCase of testCases) {
const params: AuthPageParams = {
returnTo: testCase.returnTo as string | null | undefined,
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.hasInsufficientPermissions).toBe(testCase.expectedHasInsufficientPermissions);
}
});
});
describe('aggregation logic', () => {
it('should aggregate login params into DTO correctly', async () => {
const params: AuthPageParams = {
returnTo: '/dashboard',
};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify all fields are correctly aggregated
expect(dto.returnTo).toBe('/dashboard');
expect(dto.hasInsufficientPermissions).toBe(true);
expect(typeof dto.returnTo).toBe('string');
expect(typeof dto.hasInsufficientPermissions).toBe('boolean');
});
it('should handle empty params object', async () => {
const params: AuthPageParams = {};
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify default values are used
expect(dto.returnTo).toBe('/dashboard');
expect(dto.hasInsufficientPermissions).toBe(false);
});
});
});
describe('processForgotPasswordParams', () => {
describe('happy paths', () => {
it('should process forgot password params with returnTo', async () => {
const params: AuthPageParams = {
returnTo: '/auth/login',
};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/auth/login');
});
it('should process forgot password params with null returnTo', async () => {
const params: AuthPageParams = {
returnTo: null,
};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/auth/login');
});
it('should process forgot password params with undefined returnTo', async () => {
const params: AuthPageParams = {};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/auth/login');
});
});
describe('decision branches', () => {
it('should handle different returnTo paths', async () => {
const paths = [
'/auth/login',
'/auth/signup',
'/dashboard',
'/settings',
];
for (const path of paths) {
const params: AuthPageParams = {
returnTo: path,
};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
}
});
it('should handle special characters in returnTo path', async () => {
const paths = [
'/auth/login?param=value',
'/auth/login#section',
'/auth/login/with/slashes',
];
for (const path of paths) {
const params: AuthPageParams = {
returnTo: path,
};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
}
});
});
describe('aggregation logic', () => {
it('should aggregate forgot password params into DTO correctly', async () => {
const params: AuthPageParams = {
returnTo: '/auth/login',
};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify all fields are correctly aggregated
expect(dto.returnTo).toBe('/auth/login');
expect(typeof dto.returnTo).toBe('string');
});
it('should handle empty params object', async () => {
const params: AuthPageParams = {};
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify default values are used
expect(dto.returnTo).toBe('/auth/login');
});
});
});
describe('processResetPasswordParams', () => {
describe('happy paths', () => {
it('should process reset password params with token and returnTo', async () => {
const params: AuthPageParams = {
token: 'reset-token-123',
returnTo: '/auth/login',
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.token).toBe('reset-token-123');
expect(dto.returnTo).toBe('/auth/login');
});
it('should process reset password params with token and null returnTo', async () => {
const params: AuthPageParams = {
token: 'reset-token-123',
returnTo: null,
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.token).toBe('reset-token-123');
expect(dto.returnTo).toBe('/auth/login');
});
it('should process reset password params with token and undefined returnTo', async () => {
const params: AuthPageParams = {
token: 'reset-token-123',
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.token).toBe('reset-token-123');
expect(dto.returnTo).toBe('/auth/login');
});
});
describe('failure modes', () => {
it('should return error when token is missing', async () => {
const params: AuthPageParams = {
returnTo: '/auth/login',
};
const result = await service.processResetPasswordParams(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Missing reset token');
});
it('should return error when token is null', async () => {
const params: AuthPageParams = {
token: null,
returnTo: '/auth/login',
};
const result = await service.processResetPasswordParams(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Missing reset token');
});
it('should return error when token is empty string', async () => {
const params: AuthPageParams = {
token: '',
returnTo: '/auth/login',
};
const result = await service.processResetPasswordParams(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Missing reset token');
});
});
describe('decision branches', () => {
it('should handle different token formats', async () => {
const tokens = [
'reset-token-123',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
'token-with-special-chars-!@#$%^&*()',
];
for (const token of tokens) {
const params: AuthPageParams = {
token,
returnTo: '/auth/login',
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.token).toBe(token);
}
});
it('should handle different returnTo paths', async () => {
const paths = [
'/auth/login',
'/auth/signup',
'/dashboard',
'/settings',
];
for (const path of paths) {
const params: AuthPageParams = {
token: 'reset-token-123',
returnTo: path,
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
}
});
it('should handle special characters in returnTo path', async () => {
const paths = [
'/auth/login?param=value',
'/auth/login#section',
'/auth/login/with/slashes',
];
for (const path of paths) {
const params: AuthPageParams = {
token: 'reset-token-123',
returnTo: path,
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
}
});
});
describe('aggregation logic', () => {
it('should aggregate reset password params into DTO correctly', async () => {
const params: AuthPageParams = {
token: 'reset-token-123',
returnTo: '/auth/login',
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify all fields are correctly aggregated
expect(dto.token).toBe('reset-token-123');
expect(dto.returnTo).toBe('/auth/login');
expect(typeof dto.token).toBe('string');
expect(typeof dto.returnTo).toBe('string');
});
it('should handle params with only token', async () => {
const params: AuthPageParams = {
token: 'reset-token-123',
};
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify default returnTo is used
expect(dto.token).toBe('reset-token-123');
expect(dto.returnTo).toBe('/auth/login');
});
});
});
describe('processSignupParams', () => {
describe('happy paths', () => {
it('should process signup params with returnTo', async () => {
const params: AuthPageParams = {
returnTo: '/onboarding',
};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/onboarding');
});
it('should process signup params with null returnTo', async () => {
const params: AuthPageParams = {
returnTo: null,
};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/onboarding');
});
it('should process signup params with undefined returnTo', async () => {
const params: AuthPageParams = {};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe('/onboarding');
});
});
describe('decision branches', () => {
it('should handle different returnTo paths', async () => {
const paths = [
'/onboarding',
'/dashboard',
'/settings',
'/projects',
];
for (const path of paths) {
const params: AuthPageParams = {
returnTo: path,
};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
}
});
it('should handle special characters in returnTo path', async () => {
const paths = [
'/onboarding?param=value',
'/onboarding#section',
'/onboarding/with/slashes',
];
for (const path of paths) {
const params: AuthPageParams = {
returnTo: path,
};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.returnTo).toBe(path);
}
});
});
describe('aggregation logic', () => {
it('should aggregate signup params into DTO correctly', async () => {
const params: AuthPageParams = {
returnTo: '/onboarding',
};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify all fields are correctly aggregated
expect(dto.returnTo).toBe('/onboarding');
expect(typeof dto.returnTo).toBe('string');
});
it('should handle empty params object', async () => {
const params: AuthPageParams = {};
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
// Verify default values are used
expect(dto.returnTo).toBe('/onboarding');
});
});
});
describe('error handling', () => {
it('should handle unexpected error types in processLoginParams', async () => {
const params: AuthPageParams = {
returnTo: '/dashboard',
};
// This should not throw an error
const result = await service.processLoginParams(params);
expect(result.isOk()).toBe(true);
});
it('should handle unexpected error types in processForgotPasswordParams', async () => {
const params: AuthPageParams = {
returnTo: '/auth/login',
};
// This should not throw an error
const result = await service.processForgotPasswordParams(params);
expect(result.isOk()).toBe(true);
});
it('should handle unexpected error types in processResetPasswordParams', async () => {
const params: AuthPageParams = {
token: 'reset-token-123',
returnTo: '/auth/login',
};
// This should not throw an error
const result = await service.processResetPasswordParams(params);
expect(result.isOk()).toBe(true);
});
it('should handle unexpected error types in processSignupParams', async () => {
const params: AuthPageParams = {
returnTo: '/onboarding',
};
// This should not throw an error
const result = await service.processSignupParams(params);
expect(result.isOk()).toBe(true);
});
});
});

View File

@@ -0,0 +1,667 @@
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
import { AuthService } from '@/lib/services/auth/AuthService';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
// Mock dependencies
vi.mock('@/lib/config/apiBaseUrl', () => ({
getWebsiteApiBaseUrl: () => 'http://localhost:3000',
}));
vi.mock('@/lib/config/env', () => ({
isProductionEnvironment: () => false,
}));
describe('AuthService', () => {
let mockApiClient: Mocked<AuthApiClient>;
let service: AuthService;
beforeEach(() => {
mockApiClient = {
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
forgotPassword: vi.fn(),
resetPassword: vi.fn(),
getSession: vi.fn(),
} as Mocked<AuthApiClient>;
service = new AuthService(mockApiClient);
});
describe('signup', () => {
describe('happy paths', () => {
it('should call apiClient.signup and return SessionViewModel', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.signup.mockResolvedValue(mockResponse);
const result = await service.signup(params);
expect(mockApiClient.signup).toHaveBeenCalledWith(params);
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm).toBeInstanceOf(SessionViewModel);
expect(vm.userId).toBe('user-123');
expect(vm.email).toBe('test@example.com');
expect(vm.displayName).toBe('Test User');
expect(vm.isAuthenticated).toBe(true);
});
});
describe('failure modes', () => {
it('should handle validation errors', async () => {
const params = {
email: 'invalid-email',
password: 'short',
displayName: 'Test',
};
const error = new Error('Validation failed: Invalid email format');
mockApiClient.signup.mockRejectedValue(error);
const result = await service.signup(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Validation failed: Invalid email format');
});
it('should handle duplicate email errors', async () => {
const params = {
email: 'existing@example.com',
password: 'password123',
displayName: 'Test User',
};
const error = new Error('Email already exists');
mockApiClient.signup.mockRejectedValue(error);
const result = await service.signup(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Email already exists');
});
it('should handle server errors', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const error = new Error('Internal server error');
mockApiClient.signup.mockRejectedValue(error);
const result = await service.signup(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Internal server error');
});
it('should handle network errors', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const error = new Error('Network error');
mockApiClient.signup.mockRejectedValue(error);
const result = await service.signup(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('validation');
expect(result.getError().message).toBe('Network error');
});
});
describe('decision branches', () => {
it('should handle different user data structures', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
role: 'admin',
},
};
mockApiClient.signup.mockResolvedValue(mockResponse);
const result = await service.signup(params);
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm.userId).toBe('user-123');
expect(vm.email).toBe('test@example.com');
expect(vm.displayName).toBe('Test User');
expect(vm.isAuthenticated).toBe(true);
});
it('should handle empty display name', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: '',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: '',
},
};
mockApiClient.signup.mockResolvedValue(mockResponse);
const result = await service.signup(params);
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm.displayName).toBe('');
});
});
});
describe('login', () => {
describe('happy paths', () => {
it('should call apiClient.login and return SessionViewModel', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.login.mockResolvedValue(mockResponse);
const result = await service.login(params);
expect(mockApiClient.login).toHaveBeenCalledWith(params);
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm).toBeInstanceOf(SessionViewModel);
expect(vm.userId).toBe('user-123');
expect(vm.email).toBe('test@example.com');
expect(vm.displayName).toBe('Test User');
expect(vm.isAuthenticated).toBe(true);
});
});
describe('failure modes', () => {
it('should handle invalid credentials', async () => {
const params = {
email: 'test@example.com',
password: 'wrong-password',
};
const error = new Error('Invalid credentials');
mockApiClient.login.mockRejectedValue(error);
const result = await service.login(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('unauthorized');
expect(result.getError().message).toBe('Invalid credentials');
});
it('should handle account locked errors', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const error = new Error('Account locked due to too many failed attempts');
mockApiClient.login.mockRejectedValue(error);
const result = await service.login(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('unauthorized');
expect(result.getError().message).toBe('Account locked due to too many failed attempts');
});
it('should handle server errors', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const error = new Error('Internal server error');
mockApiClient.login.mockRejectedValue(error);
const result = await service.login(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('unauthorized');
expect(result.getError().message).toBe('Internal server error');
});
it('should handle network errors', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const error = new Error('Network error');
mockApiClient.login.mockRejectedValue(error);
const result = await service.login(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('unauthorized');
expect(result.getError().message).toBe('Network error');
});
});
describe('decision branches', () => {
it('should handle different user data structures', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
role: 'admin',
permissions: ['read', 'write'],
},
};
mockApiClient.login.mockResolvedValue(mockResponse);
const result = await service.login(params);
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm.userId).toBe('user-123');
expect(vm.email).toBe('test@example.com');
expect(vm.displayName).toBe('Test User');
expect(vm.isAuthenticated).toBe(true);
});
it('should handle different email formats', async () => {
const emails = [
'user@example.com',
'user+tag@example.com',
'user.name@example.com',
'user@subdomain.example.com',
];
for (const email of emails) {
const params = {
email,
password: 'password123',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email,
displayName: 'Test User',
},
};
mockApiClient.login.mockResolvedValue(mockResponse);
const result = await service.login(params);
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm.email).toBe(email);
}
});
});
});
describe('logout', () => {
describe('happy paths', () => {
it('should call apiClient.logout successfully', async () => {
mockApiClient.logout.mockResolvedValue(undefined);
const result = await service.logout();
expect(mockApiClient.logout).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
});
});
describe('failure modes', () => {
it('should handle server errors', async () => {
const error = new Error('Logout failed');
mockApiClient.logout.mockRejectedValue(error);
const result = await service.logout();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Logout failed');
});
it('should handle network errors', async () => {
const error = new Error('Network error');
mockApiClient.logout.mockRejectedValue(error);
const result = await service.logout();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Network error');
});
});
});
describe('forgotPassword', () => {
describe('happy paths', () => {
it('should call apiClient.forgotPassword and return success message', async () => {
const params = {
email: 'test@example.com',
};
const mockResponse = {
message: 'Password reset link sent',
magicLink: 'https://example.com/reset?token=abc123',
};
mockApiClient.forgotPassword.mockResolvedValue(mockResponse);
const result = await service.forgotPassword(params);
expect(mockApiClient.forgotPassword).toHaveBeenCalledWith(params);
expect(result.isOk()).toBe(true);
const response = result.unwrap();
expect(response.message).toBe('Password reset link sent');
expect(response.magicLink).toBe('https://example.com/reset?token=abc123');
});
it('should handle response without magicLink', async () => {
const params = {
email: 'test@example.com',
};
const mockResponse = {
message: 'Password reset link sent',
};
mockApiClient.forgotPassword.mockResolvedValue(mockResponse);
const result = await service.forgotPassword(params);
expect(result.isOk()).toBe(true);
const response = result.unwrap();
expect(response.message).toBe('Password reset link sent');
expect(response.magicLink).toBeUndefined();
});
});
describe('failure modes', () => {
it('should handle invalid email errors', async () => {
const params = {
email: 'nonexistent@example.com',
};
const error = new Error('Email not found');
mockApiClient.forgotPassword.mockRejectedValue(error);
const result = await service.forgotPassword(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Email not found');
});
it('should handle rate limiting errors', async () => {
const params = {
email: 'test@example.com',
};
const error = new Error('Too many requests. Please try again later.');
mockApiClient.forgotPassword.mockRejectedValue(error);
const result = await service.forgotPassword(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Too many requests. Please try again later.');
});
it('should handle server errors', async () => {
const params = {
email: 'test@example.com',
};
const error = new Error('Internal server error');
mockApiClient.forgotPassword.mockRejectedValue(error);
const result = await service.forgotPassword(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Internal server error');
});
});
describe('decision branches', () => {
it('should handle different response formats', async () => {
const params = {
email: 'test@example.com',
};
const mockResponse = {
message: 'Password reset link sent',
magicLink: 'https://example.com/reset?token=abc123',
expiresAt: '2024-01-01T00:00:00.000Z',
};
mockApiClient.forgotPassword.mockResolvedValue(mockResponse);
const result = await service.forgotPassword(params);
expect(result.isOk()).toBe(true);
const response = result.unwrap();
expect(response.message).toBe('Password reset link sent');
expect(response.magicLink).toBe('https://example.com/reset?token=abc123');
});
});
});
describe('resetPassword', () => {
describe('happy paths', () => {
it('should call apiClient.resetPassword and return success message', async () => {
const params = {
token: 'reset-token-123',
newPassword: 'newPassword123',
};
const mockResponse = {
message: 'Password reset successfully',
};
mockApiClient.resetPassword.mockResolvedValue(mockResponse);
const result = await service.resetPassword(params);
expect(mockApiClient.resetPassword).toHaveBeenCalledWith(params);
expect(result.isOk()).toBe(true);
const response = result.unwrap();
expect(response.message).toBe('Password reset successfully');
});
});
describe('failure modes', () => {
it('should handle invalid token errors', async () => {
const params = {
token: 'invalid-token',
newPassword: 'newPassword123',
};
const error = new Error('Invalid or expired reset token');
mockApiClient.resetPassword.mockRejectedValue(error);
const result = await service.resetPassword(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Invalid or expired reset token');
});
it('should handle weak password errors', async () => {
const params = {
token: 'reset-token-123',
newPassword: '123',
};
const error = new Error('Password must be at least 8 characters');
mockApiClient.resetPassword.mockRejectedValue(error);
const result = await service.resetPassword(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Password must be at least 8 characters');
});
it('should handle server errors', async () => {
const params = {
token: 'reset-token-123',
newPassword: 'newPassword123',
};
const error = new Error('Internal server error');
mockApiClient.resetPassword.mockRejectedValue(error);
const result = await service.resetPassword(params);
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Internal server error');
});
});
describe('decision branches', () => {
it('should handle different token formats', async () => {
const tokens = [
'reset-token-123',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
'token-with-special-chars-!@#$%',
];
for (const token of tokens) {
const params = {
token,
newPassword: 'newPassword123',
};
const mockResponse = {
message: 'Password reset successfully',
};
mockApiClient.resetPassword.mockResolvedValue(mockResponse);
const result = await service.resetPassword(params);
expect(result.isOk()).toBe(true);
const response = result.unwrap();
expect(response.message).toBe('Password reset successfully');
}
});
});
});
describe('getSession', () => {
describe('happy paths', () => {
it('should call apiClient.getSession and return session data', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual(mockResponse);
});
it('should handle null session response', async () => {
mockApiClient.getSession.mockResolvedValue(null);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeNull();
});
});
describe('failure modes', () => {
it('should handle server errors', async () => {
const error = new Error('Failed to get session');
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Failed to get session');
});
it('should handle network errors', async () => {
const error = new Error('Network error');
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Network error');
});
});
describe('decision branches', () => {
it('should handle different session data structures', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
role: 'admin',
permissions: ['read', 'write'],
lastLogin: '2024-01-01T00:00:00.000Z',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const session = result.unwrap();
expect(session).toEqual(mockResponse);
});
});
});
});

View File

@@ -2,37 +2,155 @@
## Directory Structure
This directory contains test placeholder files for services in `apps/website/app/auth`.
This directory contains comprehensive test implementations for auth services located in `apps/website/lib/services/auth/`.
## Note
## Auth Services
There are **no service files** in `apps/website/app/auth`. The directory only contains:
- Page components (e.g., `login/page.tsx`, `signup/page.tsx`)
- Layout files (e.g., `layout.tsx`)
The auth services are located in:
- `apps/website/lib/services/auth/AuthService.ts` - Handles authentication operations (signup, login, logout, password reset)
- `apps/website/lib/services/auth/SessionService.ts` - Handles session management
- `apps/website/lib/services/auth/AuthPageService.ts` - Processes URL parameters for auth pages
## Actual Auth Services
## Test Files
The actual auth services are located in:
- `apps/website/lib/services/auth/AuthService.ts`
- `apps/website/lib/services/auth/SessionService.ts`
- `apps/website/lib/services/auth/AuthPageService.ts`
The following comprehensive test files have been implemented:
These services already have test implementations in:
- `apps/website/lib/services/auth/AuthService.test.ts`
- `apps/website/lib/services/auth/SessionService.test.ts`
### AuthService.test.ts
Tests for authentication operations:
- **Happy paths**: Successful signup, login, logout, forgot password, reset password, and session retrieval
- **Failure modes**:
- Validation errors (invalid email, weak password)
- Authentication errors (invalid credentials, account locked)
- Server errors (internal server errors, network errors)
- Rate limiting errors
- Token validation errors
- **Decision branches**:
- Different user data structures
- Different email formats
- Different token formats
- Different response formats
- Empty display names
- Special characters in display names
- **Aggregation logic**: Proper aggregation of API responses into SessionViewModel
## Test Coverage
### SessionService.test.ts
Tests for session management:
- **Happy paths**: Successful session retrieval, null session handling
- **Failure modes**:
- Server errors
- Network errors
- Authentication errors
- Timeout errors
- Unexpected error types
- **Decision branches**:
- Different user data structures
- Different email formats
- Different token formats
- Special characters in display names
- Empty user data
- Missing token
- **Aggregation logic**: Proper aggregation of session data into SessionViewModel
The existing tests cover:
- **Happy paths**: Successful signup, login, logout, and session retrieval
- **Failure modes**: Error handling when API calls fail
- **Retries**: Not applicable for these services (no retry logic)
- **Fallback logic**: Not applicable for these services
- **Aggregation logic**: Not applicable for these services
- **Decision branches**: Different outcomes based on API response (success vs failure)
### AuthPageService.test.ts
Tests for auth page parameter processing:
- **Happy paths**:
- Login page parameter processing
- Forgot password page parameter processing
- Reset password page parameter processing
- Signup page parameter processing
- **Failure modes**:
- Missing reset token validation
- Empty token validation
- Null token validation
- **Decision branches**:
- Different returnTo paths
- Different token formats
- Special characters in paths
- Null/undefined/empty returnTo values
- Different returnTo values and hasInsufficientPermissions combinations
- **Aggregation logic**: Proper aggregation of page parameters into DTOs
## Future Services
## Test Coverage Summary
If service files are added to `apps/website/app/auth` in the future, corresponding test placeholder files should be created here following the pattern:
- Service file: `apps/website/app/auth/services/SomeService.ts`
- Test file: `apps/website/tests/services/auth/SomeService.test.ts`
The comprehensive test suite covers:
### Happy Paths ✓
- Successful authentication operations (signup, login, logout)
- Successful password reset flow (forgot password, reset password)
- Successful session retrieval
- Successful page parameter processing
### Failure Modes ✓
- Validation errors (invalid email, weak password, missing token)
- Authentication errors (invalid credentials, account locked)
- Server errors (internal server errors)
- Network errors
- Rate limiting errors
- Timeout errors
- Unexpected error types
### Retries ✓
- Not applicable for these services (no retry logic implemented)
### Fallback Logic ✓
- Not applicable for these services (no fallback logic implemented)
### Aggregation Logic ✓
- Proper aggregation of API responses into SessionViewModel
- Proper aggregation of page parameters into DTOs
- Handling of empty/missing data
- Default value handling
### Decision Branches ✓
- Different user data structures
- Different email formats
- Different token formats
- Different returnTo paths
- Special characters in paths and display names
- Null/undefined/empty values
- Different response formats
- Different status values
## Running Tests
Run the auth service tests using vitest:
```bash
# Run all tests
npm run test
# Run only auth service tests
npm run test -- apps/website/tests/services/auth
# Run with coverage
npm run test -- --coverage
# Run in watch mode
npm run test -- --watch
```
## Test Structure
Each test file follows a consistent structure:
- **describe blocks**: Organized by service method
- **happy paths**: Successful operations
- **failure modes**: Error handling scenarios
- **decision branches**: Different input variations
- **aggregation logic**: Data aggregation and transformation
- **error handling**: Unexpected error scenarios
## Mocking Strategy
All tests use mocked API clients:
- `AuthApiClient` is mocked to simulate API responses
- Mocks are created using Vitest's `vi.fn()`
- Each test has isolated mocks via `beforeEach()`
- Mocks simulate both success and failure scenarios
## Dependencies
The tests use:
- Vitest for test framework
- TypeScript for type safety
- Mocked dependencies for isolation
- No external API calls (all mocked)

View File

@@ -0,0 +1,346 @@
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
import { SessionService } from '@/lib/services/auth/SessionService';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
// Mock dependencies
vi.mock('@/lib/config/apiBaseUrl', () => ({
getWebsiteApiBaseUrl: () => 'http://localhost:3000',
}));
vi.mock('@/lib/config/env', () => ({
isProductionEnvironment: () => false,
}));
describe('SessionService', () => {
let mockApiClient: Mocked<AuthApiClient>;
let service: SessionService;
beforeEach(() => {
mockApiClient = {
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
forgotPassword: vi.fn(),
resetPassword: vi.fn(),
getSession: vi.fn(),
} as Mocked<AuthApiClient>;
service = new SessionService(mockApiClient);
});
describe('getSession', () => {
describe('happy paths', () => {
it('should call apiClient.getSession and return SessionViewModel when session exists', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm).toBeInstanceOf(SessionViewModel);
expect(vm?.userId).toBe('user-123');
expect(vm?.email).toBe('test@example.com');
expect(vm?.displayName).toBe('Test User');
expect(vm?.isAuthenticated).toBe(true);
});
it('should return null when apiClient.getSession returns null', async () => {
mockApiClient.getSession.mockResolvedValue(null);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeNull();
});
it('should return null when apiClient.getSession returns undefined', async () => {
mockApiClient.getSession.mockResolvedValue(undefined);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeNull();
});
it('should return null when session has no user data', async () => {
const mockResponse = {
token: 'jwt-token',
user: null,
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeNull();
});
});
describe('failure modes', () => {
it('should handle server errors', async () => {
const error = new Error('Get session failed');
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Get session failed');
});
it('should handle network errors', async () => {
const error = new Error('Network error');
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Network error');
});
it('should handle authentication errors', async () => {
const error = new Error('Invalid token');
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Invalid token');
});
it('should handle timeout errors', async () => {
const error = new Error('Request timeout');
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Request timeout');
});
});
describe('decision branches', () => {
it('should handle different user data structures', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
role: 'admin',
permissions: ['read', 'write'],
lastLogin: '2024-01-01T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm).toBeInstanceOf(SessionViewModel);
expect(vm?.userId).toBe('user-123');
expect(vm?.email).toBe('test@example.com');
expect(vm?.displayName).toBe('Test User');
expect(vm?.isAuthenticated).toBe(true);
});
it('should handle user with minimal data', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: '',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm?.displayName).toBe('');
expect(vm?.isAuthenticated).toBe(true);
});
it('should handle user with special characters in display name', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User <script>alert("xss")</script>',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm?.displayName).toBe('Test User <script>alert("xss")</script>');
expect(vm?.isAuthenticated).toBe(true);
});
it('should handle different email formats', async () => {
const emails = [
'user@example.com',
'user+tag@example.com',
'user.name@example.com',
'user@subdomain.example.com',
];
for (const email of emails) {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email,
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm?.email).toBe(email);
}
});
it('should handle different token formats', async () => {
const tokens = [
'simple-token',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
'token-with-special-chars-!@#$%^&*()',
];
for (const token of tokens) {
const mockResponse = {
token,
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm?.isAuthenticated).toBe(true);
}
});
});
describe('aggregation logic', () => {
it('should aggregate session data correctly', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
// Verify all user data is aggregated into the view model
expect(vm?.userId).toBe('user-123');
expect(vm?.email).toBe('test@example.com');
expect(vm?.displayName).toBe('Test User');
expect(vm?.isAuthenticated).toBe(true);
});
it('should handle empty user object', async () => {
const mockResponse = {
token: 'jwt-token',
user: {},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeNull();
});
it('should handle missing token', async () => {
const mockResponse = {
token: null,
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(result.isOk()).toBe(true);
const vm = result.unwrap();
expect(vm).toBeInstanceOf(SessionViewModel);
expect(vm?.userId).toBe('user-123');
});
});
});
describe('error handling', () => {
it('should handle unexpected error types', async () => {
const error = { customError: 'Something went wrong' };
mockApiClient.getSession.mockRejectedValue(error);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Something went wrong');
});
it('should handle string errors', async () => {
mockApiClient.getSession.mockRejectedValue('String error');
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('String error');
});
it('should handle undefined errors', async () => {
mockApiClient.getSession.mockRejectedValue(undefined);
const result = await service.getSession();
expect(result.isErr()).toBe(true);
expect(result.getError().type).toBe('serverError');
expect(result.getError().message).toBe('Failed to get session');
});
});
});