admin area
This commit is contained in:
51
core/admin/application/ports/IAdminUserRepository.ts
Normal file
51
core/admin/application/ports/IAdminUserRepository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { UserRole } from '../../domain/value-objects/UserRole';
|
||||
import { UserStatus } from '../../domain/value-objects/UserStatus';
|
||||
|
||||
export interface UserFilter {
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
email?: Email;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface UserSort {
|
||||
field: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface UserPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UserListQuery {
|
||||
filter?: UserFilter;
|
||||
sort?: UserSort | undefined;
|
||||
pagination?: UserPagination | undefined;
|
||||
}
|
||||
|
||||
export interface UserListResult {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output port for user management operations
|
||||
* Implemented by infrastructure layer
|
||||
*/
|
||||
export interface IAdminUserRepository {
|
||||
findById(id: UserId): Promise<AdminUser | null>;
|
||||
findByEmail(email: Email): Promise<AdminUser | null>;
|
||||
emailExists(email: Email): Promise<boolean>;
|
||||
list(query?: UserListQuery): Promise<UserListResult>;
|
||||
count(filter?: UserFilter): Promise<number>;
|
||||
create(user: AdminUser): Promise<AdminUser>;
|
||||
update(user: AdminUser): Promise<AdminUser>;
|
||||
delete(id: UserId): Promise<void>;
|
||||
}
|
||||
394
core/admin/application/use-cases/ListUsersUseCase.test.ts
Normal file
394
core/admin/application/use-cases/ListUsersUseCase.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ListUsersUseCase, ListUsersResult } from './ListUsersUseCase';
|
||||
import { IAdminUserRepository } from '../ports/IAdminUserRepository';
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { AuthorizationService } from '../../domain/services/AuthorizationService';
|
||||
|
||||
// Mock the authorization service
|
||||
vi.mock('../../domain/services/AuthorizationService');
|
||||
|
||||
// Mock repository
|
||||
const mockRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as IAdminUserRepository;
|
||||
|
||||
// Mock output port
|
||||
const mockOutputPort = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<ListUsersResult>;
|
||||
|
||||
describe('ListUsersUseCase', () => {
|
||||
let useCase: ListUsersUseCase;
|
||||
let actor: AdminUser;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset all mocks
|
||||
vi.mocked(mockRepository.findById).mockReset();
|
||||
vi.mocked(mockRepository.list).mockReset();
|
||||
vi.mocked(mockRepository.count).mockReset();
|
||||
vi.mocked(AuthorizationService.canListUsers).mockReset();
|
||||
|
||||
// Setup default successful authorization
|
||||
vi.mocked(AuthorizationService.canListUsers).mockReturnValue(true);
|
||||
|
||||
useCase = new ListUsersUseCase(mockRepository, mockOutputPort);
|
||||
|
||||
// Create actor (owner)
|
||||
actor = AdminUser.create({
|
||||
id: 'actor-123',
|
||||
email: 'owner@example.com',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
displayName: 'Owner User',
|
||||
});
|
||||
|
||||
// Setup repository to return actor when findById is called
|
||||
vi.mocked(mockRepository.findById).mockResolvedValue(actor);
|
||||
});
|
||||
|
||||
describe('TDD - Test First', () => {
|
||||
it('should return empty list when no users exist', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(0);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockOutputPort.present).toHaveBeenCalledWith({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return users when they exist', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
displayName: 'User One',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
displayName: 'User Two',
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [user1, user2],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(2);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockOutputPort.present).toHaveBeenCalledWith({
|
||||
users: [user1, user2],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by role', async () => {
|
||||
// Arrange
|
||||
const adminUser = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
displayName: 'Admin User',
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [adminUser],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRepository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
role: expect.objectContaining({ value: 'admin' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
// Arrange
|
||||
const suspendedUser = AdminUser.create({
|
||||
id: 'suspended-1',
|
||||
email: 'suspended@example.com',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
displayName: 'Suspended User',
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [suspendedUser],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRepository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
status: expect.objectContaining({ value: 'suspended' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should search by email or display name', async () => {
|
||||
// Arrange
|
||||
const matchingUser = AdminUser.create({
|
||||
id: 'match-1',
|
||||
email: 'search@example.com',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
displayName: 'Search User',
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [matchingUser],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
search: 'search',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRepository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
search: 'search',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
// Arrange
|
||||
const users = Array.from({ length: 5 }, (_, i) =>
|
||||
AdminUser.create({
|
||||
id: `user-${i}`,
|
||||
email: `user${i}@example.com`,
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
displayName: `User ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: users.slice(0, 2),
|
||||
total: 5,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
totalPages: 3,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(5);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRepository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pagination: { page: 1, limit: 2 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should sort results', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(0);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
sortBy: 'email',
|
||||
sortDirection: 'desc',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRepository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sort: {
|
||||
field: 'email',
|
||||
direction: 'desc',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when actor is not authorized', async () => {
|
||||
// Arrange
|
||||
const regularUser = AdminUser.create({
|
||||
id: 'regular-1',
|
||||
email: 'regular@example.com',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
displayName: 'Regular User',
|
||||
});
|
||||
|
||||
// Mock authorization to fail
|
||||
vi.mocked(AuthorizationService.canListUsers).mockReturnValue(false);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: regularUser.id.value,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
||||
expect(error.details.message).toContain('not authorized');
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockRepository.list).mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toContain('Database connection failed');
|
||||
});
|
||||
|
||||
it('should apply multiple filters together', async () => {
|
||||
// Arrange
|
||||
const matchingUser = AdminUser.create({
|
||||
id: 'match-1',
|
||||
email: 'admin@example.com',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
displayName: 'Admin User',
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.list).mockResolvedValue({
|
||||
users: [matchingUser],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
vi.mocked(mockRepository.count).mockResolvedValue(1);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute({
|
||||
actorId: actor.id.value,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
search: 'admin',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRepository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
role: expect.objectContaining({ value: 'admin' }),
|
||||
status: expect.objectContaining({ value: 'active' }),
|
||||
search: 'admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
core/admin/application/use-cases/ListUsersUseCase.ts
Normal file
166
core/admin/application/use-cases/ListUsersUseCase.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { IAdminUserRepository } from '../ports/IAdminUserRepository';
|
||||
import { AuthorizationService } from '../../domain/services/AuthorizationService';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { UserRole } from '../../domain/value-objects/UserRole';
|
||||
import { UserStatus } from '../../domain/value-objects/UserStatus';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import type { AdminUser } from '../../domain/entities/AdminUser';
|
||||
|
||||
export type ListUsersInput = {
|
||||
actorId: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
email?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type ListUsersResult = {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export type ListUsersErrorCode =
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'AUTHORIZATION_ERROR'
|
||||
| 'REPOSITORY_ERROR';
|
||||
|
||||
export type ListUsersApplicationError = ApplicationErrorCode<ListUsersErrorCode, { message: string; details?: unknown }>;
|
||||
|
||||
/**
|
||||
* Application Use Case: ListUsersUseCase
|
||||
*
|
||||
* Lists users with filtering, sorting, and pagination.
|
||||
* Only accessible to system administrators (Owner/Admin).
|
||||
*/
|
||||
export class ListUsersUseCase {
|
||||
constructor(
|
||||
private readonly adminUserRepository: IAdminUserRepository,
|
||||
private readonly output: UseCaseOutputPort<ListUsersResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: ListUsersInput,
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
ListUsersApplicationError
|
||||
>
|
||||
> {
|
||||
try {
|
||||
// Get actor (current user)
|
||||
const actor = await this.adminUserRepository.findById(UserId.fromString(input.actorId));
|
||||
if (!actor) {
|
||||
return Result.err({
|
||||
code: 'AUTHORIZATION_ERROR',
|
||||
details: { message: 'Actor not found' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
if (!AuthorizationService.canListUsers(actor)) {
|
||||
return Result.err({
|
||||
code: 'AUTHORIZATION_ERROR',
|
||||
details: { message: 'User is not authorized to list users' },
|
||||
});
|
||||
}
|
||||
|
||||
// Build filter
|
||||
const filter: {
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
email?: Email;
|
||||
search?: string;
|
||||
} = {};
|
||||
if (input.role) {
|
||||
filter.role = UserRole.fromString(input.role);
|
||||
}
|
||||
if (input.status) {
|
||||
filter.status = UserStatus.fromString(input.status);
|
||||
}
|
||||
if (input.email) {
|
||||
filter.email = Email.fromString(input.email);
|
||||
}
|
||||
if (input.search) {
|
||||
filter.search = input.search;
|
||||
}
|
||||
|
||||
// Build sort
|
||||
const sort = input.sortBy ? {
|
||||
field: input.sortBy,
|
||||
direction: input.sortDirection || 'asc',
|
||||
} : undefined;
|
||||
|
||||
// Build pagination
|
||||
const pagination = input.page && input.limit ? {
|
||||
page: input.page,
|
||||
limit: input.limit,
|
||||
} : undefined;
|
||||
|
||||
// Execute query
|
||||
const query: {
|
||||
filter?: {
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
email?: Email;
|
||||
search?: string;
|
||||
};
|
||||
sort?: {
|
||||
field: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
if (Object.keys(filter).length > 0) {
|
||||
query.filter = filter;
|
||||
}
|
||||
if (sort) {
|
||||
query.sort = sort;
|
||||
}
|
||||
if (pagination) {
|
||||
query.pagination = pagination;
|
||||
}
|
||||
|
||||
const result = await this.adminUserRepository.list(query);
|
||||
|
||||
// Pass domain objects to output port
|
||||
this.output.present({
|
||||
users: result.users,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list users';
|
||||
|
||||
if (error instanceof Error && error.message.includes('validation')) {
|
||||
return Result.err({
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
}
|
||||
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
531
core/admin/domain/entities/AdminUser.test.ts
Normal file
531
core/admin/domain/entities/AdminUser.test.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import { AdminUser } from './AdminUser';
|
||||
import { UserRole } from '../value-objects/UserRole';
|
||||
|
||||
describe('AdminUser', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create a valid admin user', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user.id.value).toBe('user-123');
|
||||
expect(user.email.value).toBe('admin@example.com');
|
||||
expect(user.displayName).toBe('Admin User');
|
||||
expect(user.roles).toHaveLength(1);
|
||||
expect(user.roles[0]!.value).toBe('owner');
|
||||
expect(user.status.value).toBe('active');
|
||||
});
|
||||
|
||||
it('should validate email format', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'invalid-email',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert - Email should be created but validation happens in value object
|
||||
expect(user.email.value).toBe('invalid-email');
|
||||
});
|
||||
|
||||
it('should detect system admin (owner)', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(owner.isSystemAdmin()).toBe(true);
|
||||
expect(admin.isSystemAdmin()).toBe(true);
|
||||
expect(user.isSystemAdmin()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple roles', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'multi@example.com',
|
||||
displayName: 'Multi Role',
|
||||
roles: ['owner', 'admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user.roles).toHaveLength(2);
|
||||
expect(user.roles.map(r => r.value)).toContain('owner');
|
||||
expect(user.roles.map(r => r.value)).toContain('admin');
|
||||
});
|
||||
|
||||
it('should handle suspended status', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'suspended@example.com',
|
||||
displayName: 'Suspended User',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user.status.value).toBe('suspended');
|
||||
expect(user.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle optional fields', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'minimal@example.com',
|
||||
displayName: 'Minimal User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user.primaryDriverId).toBeUndefined();
|
||||
expect(user.lastLoginAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle all optional fields', () => {
|
||||
// Arrange
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'full@example.com',
|
||||
displayName: 'Full User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
primaryDriverId: 'driver-456',
|
||||
lastLoginAt: now,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user.primaryDriverId).toBe('driver-456');
|
||||
expect(user.lastLoginAt).toEqual(now);
|
||||
});
|
||||
|
||||
it('should handle createdAt and updatedAt', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user.createdAt).toBeInstanceOf(Date);
|
||||
expect(user.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should handle role assignment with validation', () => {
|
||||
// Arrange & Act
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert - Should accept any role string (validation happens in value object)
|
||||
expect(user.roles).toHaveLength(1);
|
||||
expect(user.roles[0]!.value).toBe('user');
|
||||
});
|
||||
|
||||
it('should handle status changes', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act - Use domain method to change status
|
||||
user.suspend();
|
||||
|
||||
// Assert
|
||||
expect(user.status.value).toBe('suspended');
|
||||
});
|
||||
|
||||
it('should check if user is active', () => {
|
||||
// Arrange
|
||||
const activeUser = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'active@example.com',
|
||||
displayName: 'Active User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const suspendedUser = AdminUser.create({
|
||||
id: 'user-456',
|
||||
email: 'suspended@example.com',
|
||||
displayName: 'Suspended User',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(activeUser.isActive()).toBe(true);
|
||||
expect(suspendedUser.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle role management', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
user.addRole(UserRole.fromString('admin'));
|
||||
|
||||
// Assert
|
||||
expect(user.roles).toHaveLength(2);
|
||||
expect(user.hasRole('admin')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle display name updates', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Old Name',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
user.updateDisplayName('New Name');
|
||||
|
||||
// Assert
|
||||
expect(user.displayName).toBe('New Name');
|
||||
});
|
||||
|
||||
it('should handle login recording', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const beforeLogin = user.lastLoginAt;
|
||||
|
||||
// Act
|
||||
user.recordLogin();
|
||||
|
||||
// Assert
|
||||
expect(user.lastLoginAt).toBeDefined();
|
||||
expect(user.lastLoginAt).not.toEqual(beforeLogin);
|
||||
});
|
||||
|
||||
it('should handle summary generation', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
|
||||
// Act
|
||||
const summary = user.toSummary();
|
||||
|
||||
// Assert
|
||||
expect(summary.id).toBe('user-123');
|
||||
expect(summary.email).toBe('test@example.com');
|
||||
expect(summary.displayName).toBe('Test User');
|
||||
expect(summary.roles).toEqual(['owner']);
|
||||
expect(summary.status).toBe('active');
|
||||
expect(summary.isSystemAdmin).toBe(true);
|
||||
expect(summary.lastLoginAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle equality comparison', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user3 = AdminUser.create({
|
||||
id: 'user-456',
|
||||
email: 'other@example.com',
|
||||
displayName: 'Other User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(user1.equals(user2)).toBe(true);
|
||||
expect(user1.equals(user3)).toBe(false);
|
||||
expect(user1.equals(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle management permissions', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert - Owner can manage everyone
|
||||
expect(owner.canManage(admin)).toBe(true);
|
||||
expect(owner.canManage(user)).toBe(true);
|
||||
expect(owner.canManage(owner)).toBe(true); // Can manage self
|
||||
|
||||
// Admin can manage users but not admins/owners
|
||||
expect(admin.canManage(user)).toBe(true);
|
||||
expect(admin.canManage(admin)).toBe(false);
|
||||
expect(admin.canManage(owner)).toBe(false);
|
||||
|
||||
// User cannot manage anyone except self
|
||||
expect(user.canManage(user)).toBe(true);
|
||||
expect(user.canManage(admin)).toBe(false);
|
||||
expect(user.canManage(owner)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle role modification permissions', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert - Only owner can modify roles
|
||||
expect(owner.canModifyRoles(user)).toBe(true);
|
||||
expect(owner.canModifyRoles(admin)).toBe(true);
|
||||
expect(owner.canModifyRoles(owner)).toBe(false); // Cannot modify own roles
|
||||
|
||||
expect(admin.canModifyRoles(user)).toBe(false);
|
||||
expect(user.canModifyRoles(user)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle status change permissions', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert - Owner can change anyone's status
|
||||
expect(owner.canChangeStatus(user)).toBe(true);
|
||||
expect(owner.canChangeStatus(admin)).toBe(true);
|
||||
expect(owner.canChangeStatus(owner)).toBe(false); // Cannot change own status
|
||||
|
||||
// Admin can change user status but not admin/owner
|
||||
expect(admin.canChangeStatus(user)).toBe(true);
|
||||
expect(admin.canChangeStatus(admin)).toBe(false);
|
||||
expect(admin.canChangeStatus(owner)).toBe(false);
|
||||
|
||||
// User cannot change status
|
||||
expect(user.canChangeStatus(user)).toBe(false);
|
||||
expect(user.canChangeStatus(admin)).toBe(false);
|
||||
expect(user.canChangeStatus(owner)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle deletion permissions', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert - Owner can delete anyone except self
|
||||
expect(owner.canDelete(user)).toBe(true);
|
||||
expect(owner.canDelete(admin)).toBe(true);
|
||||
expect(owner.canDelete(owner)).toBe(false); // Cannot delete self
|
||||
|
||||
// Admin can delete users but not admins/owners
|
||||
expect(admin.canDelete(user)).toBe(true);
|
||||
expect(admin.canDelete(admin)).toBe(false);
|
||||
expect(admin.canDelete(owner)).toBe(false);
|
||||
|
||||
// User cannot delete anyone
|
||||
expect(user.canDelete(user)).toBe(false);
|
||||
expect(user.canDelete(admin)).toBe(false);
|
||||
expect(user.canDelete(owner)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle authority comparison', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(owner.hasHigherAuthorityThan(admin)).toBe(true);
|
||||
expect(owner.hasHigherAuthorityThan(user)).toBe(true);
|
||||
expect(admin.hasHigherAuthorityThan(user)).toBe(true);
|
||||
expect(admin.hasHigherAuthorityThan(owner)).toBe(false);
|
||||
expect(user.hasHigherAuthorityThan(admin)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle role display names', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['owner', 'admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const displayNames = user.getRoleDisplayNames();
|
||||
|
||||
// Assert
|
||||
expect(displayNames).toContain('Owner');
|
||||
expect(displayNames).toContain('Admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
485
core/admin/domain/entities/AdminUser.ts
Normal file
485
core/admin/domain/entities/AdminUser.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
import { Email } from '../value-objects/Email';
|
||||
import { UserRole } from '../value-objects/UserRole';
|
||||
import { UserStatus } from '../value-objects/UserStatus';
|
||||
import { AdminDomainValidationError, AdminDomainInvariantError } from '../errors/AdminDomainError';
|
||||
|
||||
export interface AdminUserProps {
|
||||
id: UserId;
|
||||
email: Email;
|
||||
roles: UserRole[];
|
||||
status: UserStatus;
|
||||
displayName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt: Date | undefined;
|
||||
primaryDriverId: string | undefined;
|
||||
}
|
||||
|
||||
export class AdminUser implements IEntity<UserId> {
|
||||
readonly id: UserId;
|
||||
private _email: Email;
|
||||
private _roles: UserRole[];
|
||||
private _status: UserStatus;
|
||||
private _displayName: string;
|
||||
private _createdAt: Date;
|
||||
private _updatedAt: Date;
|
||||
private _lastLoginAt: Date | undefined;
|
||||
private _primaryDriverId: string | undefined;
|
||||
|
||||
private constructor(props: AdminUserProps) {
|
||||
this.id = props.id;
|
||||
this._email = props.email;
|
||||
this._roles = props.roles;
|
||||
this._status = props.status;
|
||||
this._displayName = props.displayName;
|
||||
this._createdAt = props.createdAt;
|
||||
this._updatedAt = props.updatedAt;
|
||||
this._lastLoginAt = props.lastLoginAt;
|
||||
this._primaryDriverId = props.primaryDriverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new AdminUser
|
||||
* Validates all business rules and invariants
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
displayName: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
}): AdminUser {
|
||||
// Validate required fields
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new AdminDomainValidationError('User ID is required');
|
||||
}
|
||||
|
||||
if (!props.email || props.email.trim().length === 0) {
|
||||
throw new AdminDomainValidationError('Email is required');
|
||||
}
|
||||
|
||||
if (!props.roles || props.roles.length === 0) {
|
||||
throw new AdminDomainValidationError('At least one role is required');
|
||||
}
|
||||
|
||||
if (!props.status || props.status.trim().length === 0) {
|
||||
throw new AdminDomainValidationError('Status is required');
|
||||
}
|
||||
|
||||
if (!props.displayName || props.displayName.trim().length === 0) {
|
||||
throw new AdminDomainValidationError('Display name is required');
|
||||
}
|
||||
|
||||
// Validate display name length
|
||||
const trimmedName = props.displayName.trim();
|
||||
if (trimmedName.length < 2 || trimmedName.length > 100) {
|
||||
throw new AdminDomainValidationError('Display name must be between 2 and 100 characters');
|
||||
}
|
||||
|
||||
// Create value objects
|
||||
const id = UserId.fromString(props.id);
|
||||
const email = Email.fromString(props.email);
|
||||
const roles = props.roles.map(role => UserRole.fromString(role));
|
||||
const status = UserStatus.fromString(props.status);
|
||||
|
||||
// Validate role hierarchy - ensure no duplicate roles
|
||||
const uniqueRoles = new Set(roles.map(r => r.toString()));
|
||||
if (uniqueRoles.size !== roles.length) {
|
||||
throw new AdminDomainValidationError('Duplicate roles are not allowed');
|
||||
}
|
||||
|
||||
const now = props.createdAt ?? new Date();
|
||||
|
||||
return new AdminUser({
|
||||
id,
|
||||
email,
|
||||
roles,
|
||||
status,
|
||||
displayName: trimmedName,
|
||||
createdAt: now,
|
||||
updatedAt: props.updatedAt ?? now,
|
||||
lastLoginAt: props.lastLoginAt ?? undefined,
|
||||
primaryDriverId: props.primaryDriverId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate from storage
|
||||
*/
|
||||
static rehydrate(props: {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
displayName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
}): AdminUser {
|
||||
return this.create(props);
|
||||
}
|
||||
|
||||
// Getters
|
||||
get email(): Email {
|
||||
return this._email;
|
||||
}
|
||||
|
||||
get roles(): UserRole[] {
|
||||
return [...this._roles];
|
||||
}
|
||||
|
||||
get status(): UserStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get displayName(): string {
|
||||
return this._displayName;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return new Date(this._createdAt.getTime());
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return new Date(this._updatedAt.getTime());
|
||||
}
|
||||
|
||||
get lastLoginAt(): Date | undefined {
|
||||
return this._lastLoginAt ? new Date(this._lastLoginAt.getTime()) : undefined;
|
||||
}
|
||||
|
||||
get primaryDriverId(): string | undefined {
|
||||
return this._primaryDriverId;
|
||||
}
|
||||
|
||||
// Domain methods
|
||||
|
||||
/**
|
||||
* Add a role to the user
|
||||
* Cannot add duplicate roles
|
||||
* Cannot add owner role if user already has other roles
|
||||
*/
|
||||
addRole(role: UserRole): void {
|
||||
if (this._roles.some(r => r.equals(role))) {
|
||||
throw new AdminDomainInvariantError(`Role ${role.value} is already assigned`);
|
||||
}
|
||||
|
||||
// If adding owner role, user must have no other roles
|
||||
if (role.value === 'owner' && this._roles.length > 0) {
|
||||
throw new AdminDomainInvariantError('Cannot add owner role to user with existing roles');
|
||||
}
|
||||
|
||||
// If user has owner role, cannot add other roles
|
||||
if (this._roles.some(r => r.value === 'owner')) {
|
||||
throw new AdminDomainInvariantError('Owner cannot have additional roles');
|
||||
}
|
||||
|
||||
this._roles.push(role);
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a role from the user
|
||||
* Cannot remove the last role
|
||||
* Cannot remove owner role (must be transferred first)
|
||||
*/
|
||||
removeRole(role: UserRole): void {
|
||||
const roleIndex = this._roles.findIndex(r => r.equals(role));
|
||||
|
||||
if (roleIndex === -1) {
|
||||
throw new AdminDomainInvariantError(`Role ${role.value} not found`);
|
||||
}
|
||||
|
||||
if (this._roles.length === 1) {
|
||||
throw new AdminDomainInvariantError('Cannot remove the last role from user');
|
||||
}
|
||||
|
||||
if (role.value === 'owner') {
|
||||
throw new AdminDomainInvariantError('Cannot remove owner role. Transfer ownership first.');
|
||||
}
|
||||
|
||||
this._roles.splice(roleIndex, 1);
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user status
|
||||
*/
|
||||
updateStatus(newStatus: UserStatus): void {
|
||||
if (this._status.equals(newStatus)) {
|
||||
throw new AdminDomainInvariantError(`User already has status ${newStatus.value}`);
|
||||
}
|
||||
|
||||
this._status = newStatus;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific role
|
||||
*/
|
||||
hasRole(roleValue: string): boolean {
|
||||
return this._roles.some(r => r.value === roleValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a system administrator
|
||||
*/
|
||||
isSystemAdmin(): boolean {
|
||||
return this._roles.some(r => r.isSystemAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has higher authority than another user
|
||||
*/
|
||||
hasHigherAuthorityThan(other: AdminUser): boolean {
|
||||
// Get highest role for each user
|
||||
const hierarchy: Record<string, number> = {
|
||||
user: 0,
|
||||
admin: 1,
|
||||
owner: 2,
|
||||
};
|
||||
|
||||
const myHighest = Math.max(...this._roles.map(r => hierarchy[r.value] ?? 0));
|
||||
const otherHighest = Math.max(...other._roles.map(r => hierarchy[r.value] ?? 0));
|
||||
|
||||
return myHighest > otherHighest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login timestamp
|
||||
*/
|
||||
recordLogin(): void {
|
||||
this._lastLoginAt = new Date();
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update display name (only for admin operations)
|
||||
*/
|
||||
updateDisplayName(newName: string): void {
|
||||
const trimmed = newName.trim();
|
||||
|
||||
if (trimmed.length < 2 || trimmed.length > 100) {
|
||||
throw new AdminDomainValidationError('Display name must be between 2 and 100 characters');
|
||||
}
|
||||
|
||||
this._displayName = trimmed;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email
|
||||
*/
|
||||
updateEmail(newEmail: Email): void {
|
||||
if (this._email.equals(newEmail)) {
|
||||
throw new AdminDomainInvariantError('Email is already the same');
|
||||
}
|
||||
|
||||
this._email = newEmail;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this._status.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend user
|
||||
*/
|
||||
suspend(): void {
|
||||
if (this._status.isSuspended()) {
|
||||
throw new AdminDomainInvariantError('User is already suspended');
|
||||
}
|
||||
|
||||
if (this._status.isDeleted()) {
|
||||
throw new AdminDomainInvariantError('Cannot suspend a deleted user');
|
||||
}
|
||||
|
||||
this._status = UserStatus.create('suspended');
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate user
|
||||
*/
|
||||
activate(): void {
|
||||
if (this._status.isActive()) {
|
||||
throw new AdminDomainInvariantError('User is already active');
|
||||
}
|
||||
|
||||
if (this._status.isDeleted()) {
|
||||
throw new AdminDomainInvariantError('Cannot activate a deleted user');
|
||||
}
|
||||
|
||||
this._status = UserStatus.create('active');
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete user
|
||||
*/
|
||||
delete(): void {
|
||||
if (this._status.isDeleted()) {
|
||||
throw new AdminDomainInvariantError('User is already deleted');
|
||||
}
|
||||
|
||||
this._status = UserStatus.create('deleted');
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role display names
|
||||
*/
|
||||
getRoleDisplayNames(): string[] {
|
||||
return this._roles.map(r => {
|
||||
switch (r.value) {
|
||||
case 'owner': return 'Owner';
|
||||
case 'admin': return 'Admin';
|
||||
case 'user': return 'User';
|
||||
default: return r.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user can manage another user
|
||||
* Owner can manage everyone (including self)
|
||||
* Admin can manage users but not admins/owners (including self)
|
||||
* User can manage self only
|
||||
*/
|
||||
canManage(target: AdminUser): boolean {
|
||||
// Owner can manage everyone
|
||||
if (this.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin can manage non-admin users
|
||||
if (this.hasRole('admin')) {
|
||||
// Cannot manage admins/owners (including self)
|
||||
if (target.isSystemAdmin()) {
|
||||
return false;
|
||||
}
|
||||
// Can manage non-admin users
|
||||
return true;
|
||||
}
|
||||
|
||||
// User can only manage self
|
||||
return this.id.equals(target.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user can modify roles of target user
|
||||
* Only owner can modify roles
|
||||
*/
|
||||
canModifyRoles(target: AdminUser): boolean {
|
||||
// Only owner can modify roles
|
||||
if (!this.hasRole('owner')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot modify own roles (prevents accidental lockout)
|
||||
if (this.id.equals(target.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user can change status of target user
|
||||
* Owner can change anyone's status
|
||||
* Admin can change user status but not other admins/owners
|
||||
*/
|
||||
canChangeStatus(target: AdminUser): boolean {
|
||||
if (this.id.equals(target.id)) {
|
||||
return false; // Cannot change own status
|
||||
}
|
||||
|
||||
if (this.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.hasRole('admin')) {
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user can delete target user
|
||||
* Owner can delete anyone except self
|
||||
* Admin can delete users but not admins/owners
|
||||
*/
|
||||
canDelete(target: AdminUser): boolean {
|
||||
if (this.id.equals(target.id)) {
|
||||
return false; // Cannot delete self
|
||||
}
|
||||
|
||||
if (this.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.hasRole('admin')) {
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary for display
|
||||
*/
|
||||
toSummary(): {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
isSystemAdmin: boolean;
|
||||
lastLoginAt?: Date;
|
||||
} {
|
||||
const summary: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
isSystemAdmin: boolean;
|
||||
lastLoginAt?: Date;
|
||||
} = {
|
||||
id: this.id.value,
|
||||
email: this._email.value,
|
||||
displayName: this._displayName,
|
||||
roles: this._roles.map(r => r.value),
|
||||
status: this._status.value,
|
||||
isSystemAdmin: this.isSystemAdmin(),
|
||||
};
|
||||
|
||||
if (this._lastLoginAt) {
|
||||
summary.lastLoginAt = this._lastLoginAt;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equals comparison
|
||||
*/
|
||||
equals(other?: AdminUser): boolean {
|
||||
if (!other) {
|
||||
return false;
|
||||
}
|
||||
return this.id.equals(other.id);
|
||||
}
|
||||
}
|
||||
45
core/admin/domain/errors/AdminDomainError.ts
Normal file
45
core/admin/domain/errors/AdminDomainError.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { IDomainError, CommonDomainErrorKind } from '@core/shared/errors';
|
||||
|
||||
export abstract class AdminDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'admin-domain';
|
||||
abstract readonly kind: CommonDomainErrorKind;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminDomainValidationError
|
||||
extends AdminDomainError
|
||||
implements IDomainError<'validation'>
|
||||
{
|
||||
readonly kind = 'validation' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminDomainInvariantError
|
||||
extends AdminDomainError
|
||||
implements IDomainError<'invariant'>
|
||||
{
|
||||
readonly kind = 'invariant' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthorizationError
|
||||
extends AdminDomainError
|
||||
implements IDomainError<'authorization'>
|
||||
{
|
||||
readonly kind = 'authorization' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
114
core/admin/domain/repositories/IAdminUserRepository.ts
Normal file
114
core/admin/domain/repositories/IAdminUserRepository.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { AdminUser } from '../entities/AdminUser';
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
import { Email } from '../value-objects/Email';
|
||||
import { UserRole } from '../value-objects/UserRole';
|
||||
import { UserStatus } from '../value-objects/UserStatus';
|
||||
|
||||
export interface UserFilter {
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
email?: Email;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface UserSort {
|
||||
field: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface UserPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UserListQuery {
|
||||
filter?: UserFilter;
|
||||
sort?: UserSort | undefined;
|
||||
pagination?: UserPagination | undefined;
|
||||
}
|
||||
|
||||
export interface UserListResult {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface StoredAdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
displayName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository interface for AdminUser entity
|
||||
* Follows clean architecture - this is an output port from application layer
|
||||
*/
|
||||
export interface IAdminUserRepository {
|
||||
/**
|
||||
* Find user by ID
|
||||
*/
|
||||
findById(id: UserId): Promise<AdminUser | null>;
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
findByEmail(email: Email): Promise<AdminUser | null>;
|
||||
|
||||
/**
|
||||
* Check if email exists
|
||||
*/
|
||||
emailExists(email: Email): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if user exists by ID
|
||||
*/
|
||||
existsById(id: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if user exists by email
|
||||
*/
|
||||
existsByEmail(email: Email): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* List users with filtering, sorting, and pagination
|
||||
*/
|
||||
list(query?: UserListQuery): Promise<UserListResult>;
|
||||
|
||||
/**
|
||||
* Count users matching filter
|
||||
*/
|
||||
count(filter?: UserFilter): Promise<number>;
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
create(user: AdminUser): Promise<AdminUser>;
|
||||
|
||||
/**
|
||||
* Update existing user
|
||||
*/
|
||||
update(user: AdminUser): Promise<AdminUser>;
|
||||
|
||||
/**
|
||||
* Delete user (soft delete)
|
||||
*/
|
||||
delete(id: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get user for storage
|
||||
*/
|
||||
toStored(user: AdminUser): StoredAdminUser;
|
||||
|
||||
/**
|
||||
* Rehydrate user from storage
|
||||
*/
|
||||
fromStored(stored: StoredAdminUser): AdminUser;
|
||||
}
|
||||
747
core/admin/domain/services/AuthorizationService.test.ts
Normal file
747
core/admin/domain/services/AuthorizationService.test.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import { AuthorizationService } from './AuthorizationService';
|
||||
import { AdminUser } from '../entities/AdminUser';
|
||||
|
||||
describe('AuthorizationService', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('canListUsers', () => {
|
||||
it('should allow owner to list users', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canList = AuthorizationService.canListUsers(owner);
|
||||
|
||||
// Assert
|
||||
expect(canList).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow admin to list users', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canList = AuthorizationService.canListUsers(admin);
|
||||
|
||||
// Assert
|
||||
expect(canList).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny regular user from listing users', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canList = AuthorizationService.canListUsers(user);
|
||||
|
||||
// Assert
|
||||
expect(canList).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny suspended admin from listing users', () => {
|
||||
// Arrange
|
||||
const suspendedAdmin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canList = AuthorizationService.canListUsers(suspendedAdmin);
|
||||
|
||||
// Assert
|
||||
expect(canList).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow owner with multiple roles to list users', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canList = AuthorizationService.canListUsers(owner);
|
||||
|
||||
// Assert
|
||||
expect(canList).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPerformAction with manage', () => {
|
||||
it('should allow owner to manage any user', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canManage = AuthorizationService.canPerformAction(owner, 'manage', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canManage).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow admin to manage non-admin users', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canManage = AuthorizationService.canPerformAction(admin, 'manage', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canManage).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny admin from managing other admins', () => {
|
||||
// Arrange
|
||||
const admin1 = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin1@example.com',
|
||||
displayName: 'Admin 1',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin2 = AdminUser.create({
|
||||
id: 'admin-2',
|
||||
email: 'admin2@example.com',
|
||||
displayName: 'Admin 2',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canManage = AuthorizationService.canPerformAction(admin1, 'manage', admin2);
|
||||
|
||||
// Assert
|
||||
expect(canManage).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny regular user from managing anyone', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canManage = AuthorizationService.canPerformAction(user, 'manage', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canManage).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow admin to manage suspended users', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const suspendedUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canManage = AuthorizationService.canPerformAction(admin, 'manage', suspendedUser);
|
||||
|
||||
// Assert
|
||||
expect(canManage).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPerformAction with modify_roles', () => {
|
||||
it('should allow owner to modify roles', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canModify = AuthorizationService.canPerformAction(owner, 'modify_roles', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canModify).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny admin from modifying roles', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canModify = AuthorizationService.canPerformAction(admin, 'modify_roles', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canModify).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny regular user from modifying roles', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canModify = AuthorizationService.canPerformAction(user, 'modify_roles', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canModify).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPerformAction with change_status', () => {
|
||||
it('should allow owner to change any status', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canChange = AuthorizationService.canPerformAction(owner, 'change_status', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canChange).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow admin to change non-admin status', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canChange = AuthorizationService.canPerformAction(admin, 'change_status', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canChange).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny admin from changing admin status', () => {
|
||||
// Arrange
|
||||
const admin1 = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin1@example.com',
|
||||
displayName: 'Admin 1',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin2 = AdminUser.create({
|
||||
id: 'admin-2',
|
||||
email: 'admin2@example.com',
|
||||
displayName: 'Admin 2',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canChange = AuthorizationService.canPerformAction(admin1, 'change_status', admin2);
|
||||
|
||||
// Assert
|
||||
expect(canChange).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny regular user from changing status', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canChange = AuthorizationService.canPerformAction(user, 'change_status', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canChange).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPerformAction with delete', () => {
|
||||
it('should allow owner to delete any user', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canDelete = AuthorizationService.canPerformAction(owner, 'delete', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow admin to delete non-admin users', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canDelete = AuthorizationService.canPerformAction(admin, 'delete', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny admin from deleting other admins', () => {
|
||||
// Arrange
|
||||
const admin1 = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin1@example.com',
|
||||
displayName: 'Admin 1',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin2 = AdminUser.create({
|
||||
id: 'admin-2',
|
||||
email: 'admin2@example.com',
|
||||
displayName: 'Admin 2',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canDelete = AuthorizationService.canPerformAction(admin1, 'delete', admin2);
|
||||
|
||||
// Assert
|
||||
expect(canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny regular user from deleting anyone', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canDelete = AuthorizationService.canPerformAction(user, 'delete', targetUser);
|
||||
|
||||
// Assert
|
||||
expect(canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow owner to delete suspended users', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const suspendedUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Act
|
||||
const canDelete = AuthorizationService.canPerformAction(owner, 'delete', suspendedUser);
|
||||
|
||||
// Assert
|
||||
expect(canDelete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPermissions', () => {
|
||||
it('should return correct permissions for owner', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const permissions = AuthorizationService.getPermissions(owner);
|
||||
|
||||
// Assert
|
||||
expect(permissions).toContain('users.view');
|
||||
expect(permissions).toContain('users.list');
|
||||
expect(permissions).toContain('users.manage');
|
||||
expect(permissions).toContain('users.roles.modify');
|
||||
expect(permissions).toContain('users.status.change');
|
||||
expect(permissions).toContain('users.create');
|
||||
expect(permissions).toContain('users.delete');
|
||||
expect(permissions).toContain('users.export');
|
||||
});
|
||||
|
||||
it('should return correct permissions for admin', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const permissions = AuthorizationService.getPermissions(admin);
|
||||
|
||||
// Assert
|
||||
expect(permissions).toContain('users.view');
|
||||
expect(permissions).toContain('users.list');
|
||||
expect(permissions).toContain('users.manage');
|
||||
expect(permissions).toContain('users.status.change');
|
||||
expect(permissions).toContain('users.create');
|
||||
expect(permissions).toContain('users.delete');
|
||||
expect(permissions).not.toContain('users.roles.modify');
|
||||
expect(permissions).not.toContain('users.export');
|
||||
});
|
||||
|
||||
it('should return empty permissions for regular user', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const permissions = AuthorizationService.getPermissions(user);
|
||||
|
||||
// Assert
|
||||
expect(permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty permissions for suspended admin', () => {
|
||||
// Arrange
|
||||
const suspendedAdmin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
// Act
|
||||
const permissions = AuthorizationService.getPermissions(suspendedAdmin);
|
||||
|
||||
// Assert
|
||||
expect(permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPermission', () => {
|
||||
it('should return true for owner with users.view permission', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const hasPermission = AuthorizationService.hasPermission(owner, 'users.view');
|
||||
|
||||
// Assert
|
||||
expect(hasPermission).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for admin with users.roles.modify permission', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const hasPermission = AuthorizationService.hasPermission(admin, 'users.roles.modify');
|
||||
|
||||
// Assert
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for regular user with any permission', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const hasPermission = AuthorizationService.hasPermission(user, 'users.view');
|
||||
|
||||
// Assert
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforce', () => {
|
||||
it('should not throw for authorized action', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
expect(() => {
|
||||
AuthorizationService.enforce(owner, 'manage', targetUser);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for unauthorized action', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const targetUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act & Assert - Should throw
|
||||
expect(() => {
|
||||
AuthorizationService.enforce(user, 'manage', targetUser);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforcePermission', () => {
|
||||
it('should not throw for authorized permission', () => {
|
||||
// Arrange
|
||||
const owner = AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
expect(() => {
|
||||
AuthorizationService.enforcePermission(owner, 'users.view');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for unauthorized permission', () => {
|
||||
// Arrange
|
||||
const admin = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act & Assert - Should throw
|
||||
expect(() => {
|
||||
AuthorizationService.enforcePermission(admin, 'users.roles.modify');
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
283
core/admin/domain/services/AuthorizationService.ts
Normal file
283
core/admin/domain/services/AuthorizationService.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { AdminUser } from '../entities/AdminUser';
|
||||
import { AuthorizationError } from '../errors/AdminDomainError';
|
||||
|
||||
/**
|
||||
* Domain service for authorization checks
|
||||
* Stateless service that enforces access control rules
|
||||
*/
|
||||
export class AuthorizationService {
|
||||
/**
|
||||
* Check if an actor can perform an action on a target user
|
||||
*/
|
||||
static canPerformAction(
|
||||
actor: AdminUser,
|
||||
action: 'view' | 'manage' | 'modify_roles' | 'change_status' | 'delete',
|
||||
target?: AdminUser
|
||||
): boolean {
|
||||
// Actors must be system admins and active
|
||||
if (!actor.isSystemAdmin() || !actor.isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'view':
|
||||
return this.canView(actor, target);
|
||||
case 'manage':
|
||||
return this.canManage(actor, target);
|
||||
case 'modify_roles':
|
||||
return this.canModifyRoles(actor, target);
|
||||
case 'change_status':
|
||||
return this.canChangeStatus(actor, target);
|
||||
case 'delete':
|
||||
return this.canDelete(actor, target);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can view target user
|
||||
*/
|
||||
private static canView(actor: AdminUser, target?: AdminUser): boolean {
|
||||
if (!target) {
|
||||
// Viewing list - only admins can view
|
||||
return actor.isSystemAdmin();
|
||||
}
|
||||
|
||||
// Can always view self
|
||||
if (actor.id.equals(target.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Owner can view everyone
|
||||
if (actor.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin can view non-admin users
|
||||
if (actor.hasRole('admin')) {
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can manage target user
|
||||
*/
|
||||
private static canManage(actor: AdminUser, target?: AdminUser): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can always manage self
|
||||
if (actor.id.equals(target.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Owner can manage everyone
|
||||
if (actor.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin can manage non-admin users
|
||||
if (actor.hasRole('admin')) {
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can modify roles of target user
|
||||
*/
|
||||
private static canModifyRoles(actor: AdminUser, target?: AdminUser): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only owner can modify roles
|
||||
if (!actor.hasRole('owner')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot modify own roles (prevents accidental lockout)
|
||||
if (actor.id.equals(target.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can change status of target user
|
||||
*/
|
||||
private static canChangeStatus(actor: AdminUser, target?: AdminUser): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot change own status
|
||||
if (actor.id.equals(target.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Owner can change anyone's status
|
||||
if (actor.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin can change user status but not other admins/owners
|
||||
if (actor.hasRole('admin')) {
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can delete target user
|
||||
*/
|
||||
private static canDelete(actor: AdminUser, target?: AdminUser): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot delete self
|
||||
if (actor.id.equals(target.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Owner can delete anyone
|
||||
if (actor.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin can delete users but not admins/owners
|
||||
if (actor.hasRole('admin')) {
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce authorization - throws if not authorized
|
||||
*/
|
||||
static enforce(
|
||||
actor: AdminUser,
|
||||
action: 'view' | 'manage' | 'modify_roles' | 'change_status' | 'delete',
|
||||
target?: AdminUser
|
||||
): void {
|
||||
if (!this.canPerformAction(actor, action, target)) {
|
||||
const actionLabel = action.replace('_', ' ');
|
||||
const targetLabel = target ? `user ${target.email.value}` : 'user list';
|
||||
throw new AuthorizationError(
|
||||
`User ${actor.email.value} is not authorized to ${actionLabel} ${targetLabel}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can list users
|
||||
*/
|
||||
static canListUsers(actor: AdminUser): boolean {
|
||||
return actor.isSystemAdmin() && actor.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can create users
|
||||
*/
|
||||
static canCreateUsers(actor: AdminUser): boolean {
|
||||
// Only owner can create users with admin roles
|
||||
// Admin can create regular users
|
||||
return actor.isSystemAdmin() && actor.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor can assign a specific role
|
||||
*/
|
||||
static canAssignRole(actor: AdminUser, roleToAssign: string, target?: AdminUser): boolean {
|
||||
// Only owner can assign owner or admin roles
|
||||
if (roleToAssign === 'owner' || roleToAssign === 'admin') {
|
||||
return actor.hasRole('owner');
|
||||
}
|
||||
|
||||
// Admin can assign user role
|
||||
if (roleToAssign === 'user') {
|
||||
// Admin can assign user role to non-admin users
|
||||
// Owner can assign user role to anyone
|
||||
if (actor.hasRole('owner')) {
|
||||
return true;
|
||||
}
|
||||
if (actor.hasRole('admin')) {
|
||||
if (!target) {
|
||||
return true;
|
||||
}
|
||||
return !target.isSystemAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions for actor
|
||||
*/
|
||||
static getPermissions(actor: AdminUser): string[] {
|
||||
const permissions: string[] = [];
|
||||
|
||||
if (!actor.isSystemAdmin() || !actor.isActive()) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
// Base permissions for all admins
|
||||
permissions.push(
|
||||
'users.view',
|
||||
'users.list'
|
||||
);
|
||||
|
||||
// Admin permissions
|
||||
if (actor.hasRole('admin')) {
|
||||
permissions.push(
|
||||
'users.manage',
|
||||
'users.status.change',
|
||||
'users.create',
|
||||
'users.delete'
|
||||
);
|
||||
}
|
||||
|
||||
// Owner permissions
|
||||
if (actor.hasRole('owner')) {
|
||||
permissions.push(
|
||||
'users.manage',
|
||||
'users.roles.modify',
|
||||
'users.status.change',
|
||||
'users.create',
|
||||
'users.delete',
|
||||
'users.export'
|
||||
);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor has specific permission
|
||||
*/
|
||||
static hasPermission(actor: AdminUser, permission: string): boolean {
|
||||
const permissions = this.getPermissions(actor);
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce permission - throws if not authorized
|
||||
*/
|
||||
static enforcePermission(actor: AdminUser, permission: string): void {
|
||||
if (!this.hasPermission(actor, permission)) {
|
||||
throw new AuthorizationError(
|
||||
`User ${actor.email.value} does not have permission: ${permission}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
core/admin/domain/value-objects/Email.test.ts
Normal file
95
core/admin/domain/value-objects/Email.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Email } from './Email';
|
||||
|
||||
describe('Email', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create a valid email from string', () => {
|
||||
// Arrange & Act
|
||||
const email = Email.fromString('test@example.com');
|
||||
|
||||
// Assert
|
||||
expect(email.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
// Arrange & Act
|
||||
const email = Email.fromString(' test@example.com ');
|
||||
|
||||
// Assert
|
||||
expect(email.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => Email.fromString('')).toThrow('Email cannot be empty');
|
||||
expect(() => Email.fromString(' ')).toThrow('Email cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for null or undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => Email.fromString(null as unknown as string)).toThrow('Email cannot be empty');
|
||||
expect(() => Email.fromString(undefined as unknown as string)).toThrow('Email cannot be empty');
|
||||
});
|
||||
|
||||
it('should handle various email formats', () => {
|
||||
// Arrange & Act
|
||||
const email1 = Email.fromString('user@example.com');
|
||||
const email2 = Email.fromString('user.name@example.com');
|
||||
const email3 = Email.fromString('user+tag@example.co.uk');
|
||||
|
||||
// Assert
|
||||
expect(email1.value).toBe('user@example.com');
|
||||
expect(email2.value).toBe('user.name@example.com');
|
||||
expect(email3.value).toBe('user+tag@example.co.uk');
|
||||
});
|
||||
|
||||
it('should support equals comparison', () => {
|
||||
// Arrange
|
||||
const email1 = Email.fromString('test@example.com');
|
||||
const email2 = Email.fromString('test@example.com');
|
||||
const email3 = Email.fromString('other@example.com');
|
||||
|
||||
// Assert
|
||||
expect(email1.equals(email2)).toBe(true);
|
||||
expect(email1.equals(email3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support toString', () => {
|
||||
// Arrange
|
||||
const email = Email.fromString('test@example.com');
|
||||
|
||||
// Assert
|
||||
expect(email.toString()).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should handle case sensitivity', () => {
|
||||
// Arrange & Act
|
||||
const email1 = Email.fromString('Test@Example.com');
|
||||
const email2 = Email.fromString('test@example.com');
|
||||
|
||||
// Assert - Should preserve case but compare as-is
|
||||
expect(email1.value).toBe('Test@Example.com');
|
||||
expect(email2.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should handle international characters', () => {
|
||||
// Arrange & Act
|
||||
const email = Email.fromString('tëst@ëxample.com');
|
||||
|
||||
// Assert
|
||||
expect(email.value).toBe('tëst@ëxample.com');
|
||||
});
|
||||
|
||||
it('should handle very long emails', () => {
|
||||
// Arrange
|
||||
const longLocal = 'a'.repeat(100);
|
||||
const longDomain = 'b'.repeat(100);
|
||||
const longEmail = `${longLocal}@${longDomain}.com`;
|
||||
|
||||
// Act
|
||||
const email = Email.fromString(longEmail);
|
||||
|
||||
// Assert
|
||||
expect(email.value).toBe(longEmail);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
core/admin/domain/value-objects/Email.ts
Normal file
46
core/admin/domain/value-objects/Email.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { IValueObject } from '@core/shared/domain';
|
||||
import { AdminDomainValidationError } from '../errors/AdminDomainError';
|
||||
|
||||
export interface EmailProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class Email implements IValueObject<EmailProps> {
|
||||
readonly value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: string): Email {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
throw new AdminDomainValidationError('Email cannot be empty');
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new AdminDomainValidationError('Email cannot be empty');
|
||||
}
|
||||
|
||||
// No format validation - accept any non-empty string
|
||||
return new Email(trimmed);
|
||||
}
|
||||
|
||||
static fromString(value: string): Email {
|
||||
return this.create(value);
|
||||
}
|
||||
|
||||
get props(): EmailProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<EmailProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
}
|
||||
90
core/admin/domain/value-objects/UserId.test.ts
Normal file
90
core/admin/domain/value-objects/UserId.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { UserId } from './UserId';
|
||||
|
||||
describe('UserId', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create a valid user id from string', () => {
|
||||
// Arrange & Act
|
||||
const userId = UserId.fromString('user-123');
|
||||
|
||||
// Assert
|
||||
expect(userId.value).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
// Arrange & Act
|
||||
const userId = UserId.fromString(' user-123 ');
|
||||
|
||||
// Assert
|
||||
expect(userId.value).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => UserId.fromString('')).toThrow('User ID cannot be empty');
|
||||
expect(() => UserId.fromString(' ')).toThrow('User ID cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for null or undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => UserId.fromString(null as unknown as string)).toThrow('User ID cannot be empty');
|
||||
expect(() => UserId.fromString(undefined as unknown as string)).toThrow('User ID cannot be empty');
|
||||
});
|
||||
|
||||
it('should handle special characters', () => {
|
||||
// Arrange & Act
|
||||
const userId = UserId.fromString('user-123_test@example');
|
||||
|
||||
// Assert
|
||||
expect(userId.value).toBe('user-123_test@example');
|
||||
});
|
||||
|
||||
it('should support equals comparison', () => {
|
||||
// Arrange
|
||||
const userId1 = UserId.fromString('user-123');
|
||||
const userId2 = UserId.fromString('user-123');
|
||||
const userId3 = UserId.fromString('user-456');
|
||||
|
||||
// Assert
|
||||
expect(userId1.equals(userId2)).toBe(true);
|
||||
expect(userId1.equals(userId3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support toString', () => {
|
||||
// Arrange
|
||||
const userId = UserId.fromString('user-123');
|
||||
|
||||
// Assert
|
||||
expect(userId.toString()).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should handle very long IDs', () => {
|
||||
// Arrange
|
||||
const longId = 'a'.repeat(1000);
|
||||
|
||||
// Act
|
||||
const userId = UserId.fromString(longId);
|
||||
|
||||
// Assert
|
||||
expect(userId.value).toBe(longId);
|
||||
});
|
||||
|
||||
it('should handle UUID format', () => {
|
||||
// Arrange
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
// Act
|
||||
const userId = UserId.fromString(uuid);
|
||||
|
||||
// Assert
|
||||
expect(userId.value).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should handle numeric string IDs', () => {
|
||||
// Arrange & Act
|
||||
const userId = UserId.fromString('123456');
|
||||
|
||||
// Assert
|
||||
expect(userId.value).toBe('123456');
|
||||
});
|
||||
});
|
||||
});
|
||||
38
core/admin/domain/value-objects/UserId.ts
Normal file
38
core/admin/domain/value-objects/UserId.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { IValueObject } from '@core/shared/domain';
|
||||
import { AdminDomainValidationError } from '../errors/AdminDomainError';
|
||||
|
||||
export interface UserIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class UserId implements IValueObject<UserIdProps> {
|
||||
readonly value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(id: string): UserId {
|
||||
if (!id || id.trim().length === 0) {
|
||||
throw new AdminDomainValidationError('User ID cannot be empty');
|
||||
}
|
||||
|
||||
return new UserId(id.trim());
|
||||
}
|
||||
|
||||
static fromString(id: string): UserId {
|
||||
return this.create(id);
|
||||
}
|
||||
|
||||
get props(): UserIdProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<UserIdProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
103
core/admin/domain/value-objects/UserRole.test.ts
Normal file
103
core/admin/domain/value-objects/UserRole.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { UserRole } from './UserRole';
|
||||
|
||||
describe('UserRole', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create a valid role from string', () => {
|
||||
// Arrange & Act
|
||||
const role = UserRole.fromString('owner');
|
||||
|
||||
// Assert
|
||||
expect(role.value).toBe('owner');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
// Arrange & Act
|
||||
const role = UserRole.fromString(' admin ');
|
||||
|
||||
// Assert
|
||||
expect(role.value).toBe('admin');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => UserRole.fromString('')).toThrow('Role cannot be empty');
|
||||
expect(() => UserRole.fromString(' ')).toThrow('Role cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for null or undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => UserRole.fromString(null as unknown as string)).toThrow('Role cannot be empty');
|
||||
expect(() => UserRole.fromString(undefined as unknown as string)).toThrow('Role cannot be empty');
|
||||
});
|
||||
|
||||
it('should handle all valid roles', () => {
|
||||
// Arrange & Act
|
||||
const owner = UserRole.fromString('owner');
|
||||
const admin = UserRole.fromString('admin');
|
||||
const user = UserRole.fromString('user');
|
||||
|
||||
// Assert
|
||||
expect(owner.value).toBe('owner');
|
||||
expect(admin.value).toBe('admin');
|
||||
expect(user.value).toBe('user');
|
||||
});
|
||||
|
||||
it('should detect system admin roles', () => {
|
||||
// Arrange
|
||||
const owner = UserRole.fromString('owner');
|
||||
const admin = UserRole.fromString('admin');
|
||||
const user = UserRole.fromString('user');
|
||||
|
||||
// Assert
|
||||
expect(owner.isSystemAdmin()).toBe(true);
|
||||
expect(admin.isSystemAdmin()).toBe(true);
|
||||
expect(user.isSystemAdmin()).toBe(false);
|
||||
});
|
||||
|
||||
it('should support equals comparison', () => {
|
||||
// Arrange
|
||||
const role1 = UserRole.fromString('owner');
|
||||
const role2 = UserRole.fromString('owner');
|
||||
const role3 = UserRole.fromString('admin');
|
||||
|
||||
// Assert
|
||||
expect(role1.equals(role2)).toBe(true);
|
||||
expect(role1.equals(role3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support toString', () => {
|
||||
// Arrange
|
||||
const role = UserRole.fromString('owner');
|
||||
|
||||
// Assert
|
||||
expect(role.toString()).toBe('owner');
|
||||
});
|
||||
|
||||
it('should handle custom roles', () => {
|
||||
// Arrange & Act
|
||||
const customRole = UserRole.fromString('steward');
|
||||
|
||||
// Assert
|
||||
expect(customRole.value).toBe('steward');
|
||||
expect(customRole.isSystemAdmin()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity', () => {
|
||||
// Arrange & Act
|
||||
const role1 = UserRole.fromString('Owner');
|
||||
const role2 = UserRole.fromString('owner');
|
||||
|
||||
// Assert - Should preserve case but compare as-is
|
||||
expect(role1.value).toBe('Owner');
|
||||
expect(role2.value).toBe('owner');
|
||||
});
|
||||
|
||||
it('should handle special characters in role names', () => {
|
||||
// Arrange & Act
|
||||
const role = UserRole.fromString('admin-steward');
|
||||
|
||||
// Assert
|
||||
expect(role.value).toBe('admin-steward');
|
||||
});
|
||||
});
|
||||
});
|
||||
74
core/admin/domain/value-objects/UserRole.ts
Normal file
74
core/admin/domain/value-objects/UserRole.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { IValueObject } from '@core/shared/domain';
|
||||
import { AdminDomainValidationError } from '../errors/AdminDomainError';
|
||||
|
||||
export type UserRoleValue = string;
|
||||
|
||||
export interface UserRoleProps {
|
||||
value: UserRoleValue;
|
||||
}
|
||||
|
||||
export class UserRole implements IValueObject<UserRoleProps> {
|
||||
readonly value: UserRoleValue;
|
||||
|
||||
private constructor(value: UserRoleValue) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: UserRoleValue): UserRole {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
throw new AdminDomainValidationError('Role cannot be empty');
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new AdminDomainValidationError('Role cannot be empty');
|
||||
}
|
||||
|
||||
return new UserRole(trimmed);
|
||||
}
|
||||
|
||||
static fromString(value: string): UserRole {
|
||||
return this.create(value);
|
||||
}
|
||||
|
||||
get props(): UserRoleProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
toString(): UserRoleValue {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<UserRoleProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role is a system administrator role
|
||||
*/
|
||||
isSystemAdmin(): boolean {
|
||||
const lower = this.value.toLowerCase();
|
||||
return lower === 'owner' || lower === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has higher authority than another role
|
||||
*/
|
||||
hasHigherAuthorityThan(other: UserRole): boolean {
|
||||
const hierarchy: Record<string, number> = {
|
||||
user: 0,
|
||||
admin: 1,
|
||||
owner: 2,
|
||||
};
|
||||
|
||||
const myValue = this.value.toLowerCase();
|
||||
const otherValue = other.value.toLowerCase();
|
||||
|
||||
const myRank = hierarchy[myValue] ?? 0;
|
||||
const otherRank = hierarchy[otherValue] ?? 0;
|
||||
|
||||
return myRank > otherRank;
|
||||
}
|
||||
}
|
||||
127
core/admin/domain/value-objects/UserStatus.test.ts
Normal file
127
core/admin/domain/value-objects/UserStatus.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { UserStatus } from './UserStatus';
|
||||
|
||||
describe('UserStatus', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
it('should create a valid status from string', () => {
|
||||
// Arrange & Act
|
||||
const status = UserStatus.fromString('active');
|
||||
|
||||
// Assert
|
||||
expect(status.value).toBe('active');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
// Arrange & Act
|
||||
const status = UserStatus.fromString(' suspended ');
|
||||
|
||||
// Assert
|
||||
expect(status.value).toBe('suspended');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => UserStatus.fromString('')).toThrow('Status cannot be empty');
|
||||
expect(() => UserStatus.fromString(' ')).toThrow('Status cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw error for null or undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => UserStatus.fromString(null as unknown as string)).toThrow('Status cannot be empty');
|
||||
expect(() => UserStatus.fromString(undefined as unknown as string)).toThrow('Status cannot be empty');
|
||||
});
|
||||
|
||||
it('should handle all valid statuses', () => {
|
||||
// Arrange & Act
|
||||
const active = UserStatus.fromString('active');
|
||||
const suspended = UserStatus.fromString('suspended');
|
||||
const deleted = UserStatus.fromString('deleted');
|
||||
|
||||
// Assert
|
||||
expect(active.value).toBe('active');
|
||||
expect(suspended.value).toBe('suspended');
|
||||
expect(deleted.value).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should detect active status', () => {
|
||||
// Arrange
|
||||
const active = UserStatus.fromString('active');
|
||||
const suspended = UserStatus.fromString('suspended');
|
||||
const deleted = UserStatus.fromString('deleted');
|
||||
|
||||
// Assert
|
||||
expect(active.isActive()).toBe(true);
|
||||
expect(suspended.isActive()).toBe(false);
|
||||
expect(deleted.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect suspended status', () => {
|
||||
// Arrange
|
||||
const active = UserStatus.fromString('active');
|
||||
const suspended = UserStatus.fromString('suspended');
|
||||
const deleted = UserStatus.fromString('deleted');
|
||||
|
||||
// Assert
|
||||
expect(active.isSuspended()).toBe(false);
|
||||
expect(suspended.isSuspended()).toBe(true);
|
||||
expect(deleted.isSuspended()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect deleted status', () => {
|
||||
// Arrange
|
||||
const active = UserStatus.fromString('active');
|
||||
const suspended = UserStatus.fromString('suspended');
|
||||
const deleted = UserStatus.fromString('deleted');
|
||||
|
||||
// Assert
|
||||
expect(active.isDeleted()).toBe(false);
|
||||
expect(suspended.isDeleted()).toBe(false);
|
||||
expect(deleted.isDeleted()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support equals comparison', () => {
|
||||
// Arrange
|
||||
const status1 = UserStatus.fromString('active');
|
||||
const status2 = UserStatus.fromString('active');
|
||||
const status3 = UserStatus.fromString('suspended');
|
||||
|
||||
// Assert
|
||||
expect(status1.equals(status2)).toBe(true);
|
||||
expect(status1.equals(status3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support toString', () => {
|
||||
// Arrange
|
||||
const status = UserStatus.fromString('active');
|
||||
|
||||
// Assert
|
||||
expect(status.toString()).toBe('active');
|
||||
});
|
||||
|
||||
it('should handle custom statuses', () => {
|
||||
// Arrange & Act
|
||||
const customStatus = UserStatus.fromString('pending');
|
||||
|
||||
// Assert
|
||||
expect(customStatus.value).toBe('pending');
|
||||
expect(customStatus.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity', () => {
|
||||
// Arrange & Act
|
||||
const status1 = UserStatus.fromString('Active');
|
||||
const status2 = UserStatus.fromString('active');
|
||||
|
||||
// Assert - Should preserve case but compare as-is
|
||||
expect(status1.value).toBe('Active');
|
||||
expect(status2.value).toBe('active');
|
||||
});
|
||||
|
||||
it('should handle special characters in status names', () => {
|
||||
// Arrange & Act
|
||||
const status = UserStatus.fromString('under-review');
|
||||
|
||||
// Assert
|
||||
expect(status.value).toBe('under-review');
|
||||
});
|
||||
});
|
||||
});
|
||||
59
core/admin/domain/value-objects/UserStatus.ts
Normal file
59
core/admin/domain/value-objects/UserStatus.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { IValueObject } from '@core/shared/domain';
|
||||
import { AdminDomainValidationError } from '../errors/AdminDomainError';
|
||||
|
||||
export type UserStatusValue = string;
|
||||
|
||||
export interface UserStatusProps {
|
||||
value: UserStatusValue;
|
||||
}
|
||||
|
||||
export class UserStatus implements IValueObject<UserStatusProps> {
|
||||
readonly value: UserStatusValue;
|
||||
|
||||
private constructor(value: UserStatusValue) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: UserStatusValue): UserStatus {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
throw new AdminDomainValidationError('Status cannot be empty');
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new AdminDomainValidationError('Status cannot be empty');
|
||||
}
|
||||
|
||||
return new UserStatus(trimmed);
|
||||
}
|
||||
|
||||
static fromString(value: string): UserStatus {
|
||||
return this.create(value);
|
||||
}
|
||||
|
||||
get props(): UserStatusProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
toString(): UserStatusValue {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<UserStatusProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.value === 'active';
|
||||
}
|
||||
|
||||
isSuspended(): boolean {
|
||||
return this.value === 'suspended';
|
||||
}
|
||||
|
||||
isDeleted(): boolean {
|
||||
return this.value === 'deleted';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
import { InMemoryAdminUserRepository } from './InMemoryAdminUserRepository';
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UserRole } from '../../domain/value-objects/UserRole';
|
||||
import { UserStatus } from '../../domain/value-objects/UserStatus';
|
||||
|
||||
describe('InMemoryAdminUserRepository', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
let repository: InMemoryAdminUserRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryAdminUserRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new user', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await repository.create(user);
|
||||
|
||||
// Assert
|
||||
expect(result).toStrictEqual(user);
|
||||
const found = await repository.findById(user.id);
|
||||
expect(found).toStrictEqual(user);
|
||||
});
|
||||
|
||||
it('should throw error when creating user with duplicate email', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'test@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
|
||||
// Act & Assert
|
||||
await expect(repository.create(user2)).rejects.toThrow('Email already exists');
|
||||
});
|
||||
|
||||
it('should throw error when creating user with duplicate ID', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
|
||||
// Act & Assert
|
||||
await expect(repository.create(user2)).rejects.toThrow('User ID already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find user by ID', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user);
|
||||
|
||||
// Act
|
||||
const found = await repository.findById(user.id);
|
||||
|
||||
// Assert
|
||||
expect(found).toStrictEqual(user);
|
||||
});
|
||||
|
||||
it('should return null for non-existent user', async () => {
|
||||
// Arrange
|
||||
const nonExistentId = AdminUser.create({
|
||||
id: 'non-existent',
|
||||
email: 'dummy@example.com',
|
||||
displayName: 'Dummy',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}).id;
|
||||
|
||||
// Act
|
||||
const found = await repository.findById(nonExistentId);
|
||||
|
||||
// Assert
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail', () => {
|
||||
it('should find user by email', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user);
|
||||
|
||||
// Act
|
||||
const found = await repository.findByEmail(user.email);
|
||||
|
||||
// Assert
|
||||
expect(found).toStrictEqual(user);
|
||||
});
|
||||
|
||||
it('should return null for non-existent email', async () => {
|
||||
// Arrange
|
||||
const nonExistentEmail = AdminUser.create({
|
||||
id: 'dummy',
|
||||
email: 'non-existent@example.com',
|
||||
displayName: 'Dummy',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}).email;
|
||||
|
||||
// Act
|
||||
const found = await repository.findByEmail(nonExistentEmail);
|
||||
|
||||
// Assert
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emailExists', () => {
|
||||
it('should return true when email exists', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user);
|
||||
|
||||
// Act
|
||||
const exists = await repository.emailExists(user.email);
|
||||
|
||||
// Assert
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when email does not exist', async () => {
|
||||
// Arrange
|
||||
const nonExistentEmail = AdminUser.create({
|
||||
id: 'dummy',
|
||||
email: 'non-existent@example.com',
|
||||
displayName: 'Dummy',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}).email;
|
||||
|
||||
// Act
|
||||
const exists = await repository.emailExists(nonExistentEmail);
|
||||
|
||||
// Assert
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should return empty list when no users exist', async () => {
|
||||
// Act
|
||||
const result = await repository.list({});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(0);
|
||||
});
|
||||
|
||||
it('should return all users when no filters provided', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by role', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const admin1 = AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin1@example.com',
|
||||
displayName: 'Admin 1',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(admin1);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
filter: { role: UserRole.fromString('admin') },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.users[0]?.id.value).toBe('admin-1');
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
// Arrange
|
||||
const activeUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'active@example.com',
|
||||
displayName: 'Active User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const suspendedUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'suspended@example.com',
|
||||
displayName: 'Suspended User',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
await repository.create(activeUser);
|
||||
await repository.create(suspendedUser);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
filter: { status: UserStatus.fromString('suspended') },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.users[0]?.id.value).toBe('user-2');
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by email', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'other@example.com',
|
||||
displayName: 'Other User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
filter: { email: user1.email },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.users[0]?.id.value).toBe('user-1');
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by search (email or display name)', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'search@example.com',
|
||||
displayName: 'Search User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'other@example.com',
|
||||
displayName: 'Other User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
filter: { search: 'search' },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.users[0]?.id.value).toBe('user-1');
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should apply pagination', async () => {
|
||||
// Arrange - Create 15 users
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
const user = AdminUser.create({
|
||||
id: `user-${i}`,
|
||||
email: `user${i}@example.com`,
|
||||
displayName: `User ${i}`,
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
await repository.create(user);
|
||||
}
|
||||
|
||||
// Act - Get page 2 with limit 5
|
||||
const result = await repository.list({
|
||||
pagination: { page: 2, limit: 5 },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(5);
|
||||
expect(result.total).toBe(15);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.limit).toBe(5);
|
||||
expect(result.totalPages).toBe(3);
|
||||
expect(result.users[0]?.id.value).toBe('user-6');
|
||||
});
|
||||
|
||||
it('should sort by email ascending', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'zebra@example.com',
|
||||
displayName: 'Zebra',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'alpha@example.com',
|
||||
displayName: 'Alpha',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
sort: { field: 'email', direction: 'asc' },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.users[0]?.id.value).toBe('user-2');
|
||||
expect(result.users[1]?.id.value).toBe('user-1');
|
||||
});
|
||||
|
||||
it('should sort by display name descending', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'Zebra',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'Alpha',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
sort: { field: 'displayName', direction: 'desc' },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.users[0]?.id.value).toBe('user-1');
|
||||
expect(result.users[1]?.id.value).toBe('user-2');
|
||||
});
|
||||
|
||||
it('should apply multiple filters together', async () => {
|
||||
// Arrange
|
||||
const matchingUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const nonMatchingUser1 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const nonMatchingUser2 = AdminUser.create({
|
||||
id: 'user-3',
|
||||
email: 'admin-suspended@example.com',
|
||||
displayName: 'Admin User',
|
||||
roles: ['admin'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
await repository.create(matchingUser);
|
||||
await repository.create(nonMatchingUser1);
|
||||
await repository.create(nonMatchingUser2);
|
||||
|
||||
// Act
|
||||
const result = await repository.list({
|
||||
filter: {
|
||||
role: UserRole.fromString('admin'),
|
||||
status: UserStatus.fromString('active'),
|
||||
search: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.users[0]?.id.value).toBe('user-1');
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('should return 0 when no users exist', async () => {
|
||||
// Act
|
||||
const count = await repository.count();
|
||||
|
||||
// Assert
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct count of all users', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act
|
||||
const count = await repository.count();
|
||||
|
||||
// Assert
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('should count with filters', async () => {
|
||||
// Arrange
|
||||
const activeUser = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'active@example.com',
|
||||
displayName: 'Active User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const suspendedUser = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'suspended@example.com',
|
||||
displayName: 'Suspended User',
|
||||
roles: ['user'],
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
await repository.create(activeUser);
|
||||
await repository.create(suspendedUser);
|
||||
|
||||
// Act
|
||||
const count = await repository.count({
|
||||
status: UserStatus.fromString('active'),
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update existing user', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user);
|
||||
|
||||
// Act
|
||||
user.updateDisplayName('Updated Name');
|
||||
const updated = await repository.update(user);
|
||||
|
||||
// Assert
|
||||
expect(updated.displayName).toBe('Updated Name');
|
||||
const found = await repository.findById(user.id);
|
||||
expect(found?.displayName).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent user', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'non-existent',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await expect(repository.update(user)).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('should throw error when updating with duplicate email', async () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user1);
|
||||
await repository.create(user2);
|
||||
|
||||
// Act - Try to change user2's email to user1's email
|
||||
user2.updateEmail(user1.email);
|
||||
|
||||
// Assert
|
||||
await expect(repository.update(user2)).rejects.toThrow('Email already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete existing user', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user);
|
||||
|
||||
// Act
|
||||
await repository.delete(user.id);
|
||||
|
||||
// Assert
|
||||
const found = await repository.findById(user.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when deleting non-existent user', async () => {
|
||||
// Arrange
|
||||
const nonExistentId = AdminUser.create({
|
||||
id: 'non-existent',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}).id;
|
||||
|
||||
// Act & Assert
|
||||
await expect(repository.delete(nonExistentId)).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('should reduce count after deletion', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repository.create(user);
|
||||
const initialCount = await repository.count();
|
||||
|
||||
// Act
|
||||
await repository.delete(user.id);
|
||||
|
||||
// Assert
|
||||
const finalCount = await repository.count();
|
||||
expect(finalCount).toBe(initialCount - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('should handle complete user lifecycle', async () => {
|
||||
// Arrange - Create user
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'lifecycle@example.com',
|
||||
displayName: 'Lifecycle User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Act - Create
|
||||
await repository.create(user);
|
||||
let found = await repository.findById(user.id);
|
||||
expect(found).toStrictEqual(user);
|
||||
|
||||
// Act - Update
|
||||
user.updateDisplayName('Updated Lifecycle');
|
||||
user.addRole(UserRole.fromString('admin'));
|
||||
await repository.update(user);
|
||||
found = await repository.findById(user.id);
|
||||
expect(found?.displayName).toBe('Updated Lifecycle');
|
||||
expect(found?.hasRole('admin')).toBe(true);
|
||||
|
||||
// Act - Delete
|
||||
await repository.delete(user.id);
|
||||
found = await repository.findById(user.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle complex filtering and pagination', async () => {
|
||||
// Arrange - Create mixed users
|
||||
const users = [
|
||||
AdminUser.create({
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
displayName: 'Owner User',
|
||||
roles: ['owner'],
|
||||
status: 'active',
|
||||
}),
|
||||
AdminUser.create({
|
||||
id: 'admin-1',
|
||||
email: 'admin1@example.com',
|
||||
displayName: 'Admin One',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
}),
|
||||
AdminUser.create({
|
||||
id: 'admin-2',
|
||||
email: 'admin2@example.com',
|
||||
displayName: 'Admin Two',
|
||||
roles: ['admin'],
|
||||
status: 'suspended',
|
||||
}),
|
||||
AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User One',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}),
|
||||
AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User Two',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}),
|
||||
];
|
||||
|
||||
for (const user of users) {
|
||||
await repository.create(user);
|
||||
}
|
||||
|
||||
// Act - Get active admins, sorted by email, page 1, limit 2
|
||||
const result = await repository.list({
|
||||
filter: {
|
||||
role: UserRole.fromString('admin'),
|
||||
status: UserStatus.fromString('active'),
|
||||
},
|
||||
sort: { field: 'email', direction: 'asc' },
|
||||
pagination: { page: 1, limit: 2 },
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.users[0]?.id.value).toBe('admin-1');
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { IAdminUserRepository, UserFilter, UserListQuery, UserListResult, StoredAdminUser } from '../../domain/repositories/IAdminUserRepository';
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
|
||||
/**
|
||||
* In-memory implementation of AdminUserRepository for testing and development
|
||||
* Follows TDD - created with tests first
|
||||
*/
|
||||
export class InMemoryAdminUserRepository implements IAdminUserRepository {
|
||||
private storage: Map<string, StoredAdminUser> = new Map();
|
||||
|
||||
async findById(id: UserId): Promise<AdminUser | null> {
|
||||
const stored = this.storage.get(id.value);
|
||||
return stored ? this.fromStored(stored) : null;
|
||||
}
|
||||
|
||||
async findByEmail(email: Email): Promise<AdminUser | null> {
|
||||
for (const stored of this.storage.values()) {
|
||||
if (stored.email === email.value) {
|
||||
return this.fromStored(stored);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async emailExists(email: Email): Promise<boolean> {
|
||||
for (const stored of this.storage.values()) {
|
||||
if (stored.email === email.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async existsById(id: UserId): Promise<boolean> {
|
||||
return this.storage.has(id.value);
|
||||
}
|
||||
|
||||
async existsByEmail(email: Email): Promise<boolean> {
|
||||
return this.emailExists(email);
|
||||
}
|
||||
|
||||
async list(query?: UserListQuery): Promise<UserListResult> {
|
||||
let users: AdminUser[] = [];
|
||||
|
||||
// Get all users
|
||||
for (const stored of this.storage.values()) {
|
||||
users.push(this.fromStored(stored));
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (query?.filter) {
|
||||
users = users.filter(user => {
|
||||
if (query.filter?.role && !user.roles.some(r => r.equals(query.filter!.role!))) {
|
||||
return false;
|
||||
}
|
||||
if (query.filter?.status && !user.status.equals(query.filter.status)) {
|
||||
return false;
|
||||
}
|
||||
if (query.filter?.email && !user.email.equals(query.filter.email)) {
|
||||
return false;
|
||||
}
|
||||
if (query.filter?.search) {
|
||||
const search = query.filter.search.toLowerCase();
|
||||
const matchesEmail = user.email.value.toLowerCase().includes(search);
|
||||
const matchesDisplayName = user.displayName.toLowerCase().includes(search);
|
||||
if (!matchesEmail && !matchesDisplayName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const total = users.length;
|
||||
|
||||
// Apply sorting
|
||||
if (query?.sort) {
|
||||
const { field, direction } = query.sort;
|
||||
users.sort((a, b) => {
|
||||
let aVal: string | number | Date;
|
||||
let bVal: string | number | Date;
|
||||
|
||||
switch (field) {
|
||||
case 'email':
|
||||
aVal = a.email.value;
|
||||
bVal = b.email.value;
|
||||
break;
|
||||
case 'displayName':
|
||||
aVal = a.displayName;
|
||||
bVal = b.displayName;
|
||||
break;
|
||||
case 'createdAt':
|
||||
aVal = a.createdAt;
|
||||
bVal = b.createdAt;
|
||||
break;
|
||||
case 'lastLoginAt':
|
||||
aVal = a.lastLoginAt || 0;
|
||||
bVal = b.lastLoginAt || 0;
|
||||
break;
|
||||
case 'status':
|
||||
aVal = a.status.value;
|
||||
bVal = b.status.value;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aVal < bVal) return direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const page = query?.pagination?.page || 1;
|
||||
const limit = query?.pagination?.limit || 10;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginatedUsers = users.slice(start, end);
|
||||
|
||||
return {
|
||||
users: paginatedUsers,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
async count(filter?: UserFilter): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for (const stored of this.storage.values()) {
|
||||
const user = this.fromStored(stored);
|
||||
|
||||
if (filter?.role && !user.roles.some(r => r.equals(filter.role!))) {
|
||||
continue;
|
||||
}
|
||||
if (filter?.status && !user.status.equals(filter.status)) {
|
||||
continue;
|
||||
}
|
||||
if (filter?.email && !user.email.equals(filter.email)) {
|
||||
continue;
|
||||
}
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
const matchesEmail = user.email.value.toLowerCase().includes(search);
|
||||
const matchesDisplayName = user.displayName.toLowerCase().includes(search);
|
||||
if (!matchesEmail && !matchesDisplayName) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async create(user: AdminUser): Promise<AdminUser> {
|
||||
// Check for duplicate email
|
||||
if (await this.emailExists(user.email)) {
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
|
||||
// Check for duplicate ID
|
||||
if (this.storage.has(user.id.value)) {
|
||||
throw new Error('User ID already exists');
|
||||
}
|
||||
|
||||
const stored = this.toStored(user);
|
||||
this.storage.set(user.id.value, stored);
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(user: AdminUser): Promise<AdminUser> {
|
||||
// Check if user exists
|
||||
if (!this.storage.has(user.id.value)) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Check for duplicate email (excluding current user)
|
||||
for (const [id, stored] of this.storage.entries()) {
|
||||
if (id !== user.id.value && stored.email === user.email.value) {
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const stored = this.toStored(user);
|
||||
this.storage.set(user.id.value, stored);
|
||||
return user;
|
||||
}
|
||||
|
||||
async delete(id: UserId): Promise<void> {
|
||||
if (!this.storage.has(id.value)) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
this.storage.delete(id.value);
|
||||
}
|
||||
|
||||
toStored(user: AdminUser): StoredAdminUser {
|
||||
const stored: StoredAdminUser = {
|
||||
id: user.id.value,
|
||||
email: user.email.value,
|
||||
roles: user.roles.map(r => r.value),
|
||||
status: user.status.value,
|
||||
displayName: user.displayName,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
|
||||
if (user.lastLoginAt !== undefined) {
|
||||
stored.lastLoginAt = user.lastLoginAt;
|
||||
}
|
||||
|
||||
if (user.primaryDriverId !== undefined) {
|
||||
stored.primaryDriverId = user.primaryDriverId;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
fromStored(stored: StoredAdminUser): AdminUser {
|
||||
const props: {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
displayName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
} = {
|
||||
id: stored.id,
|
||||
email: stored.email,
|
||||
roles: stored.roles,
|
||||
status: stored.status,
|
||||
displayName: stored.displayName,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
|
||||
if (stored.lastLoginAt !== undefined) {
|
||||
props.lastLoginAt = stored.lastLoginAt;
|
||||
}
|
||||
|
||||
if (stored.primaryDriverId !== undefined) {
|
||||
props.primaryDriverId = stored.primaryDriverId;
|
||||
}
|
||||
|
||||
return AdminUser.rehydrate(props);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'admin_users' })
|
||||
export class AdminUserOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text', unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
displayName!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
roles!: string[];
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
primaryDriverId?: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
lastLoginAt?: Date;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class TypeOrmAdminSchemaError extends Error {
|
||||
constructor(
|
||||
public details: {
|
||||
entityName: string;
|
||||
fieldName: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
super(`[TypeOrmAdminSchemaError] ${details.entityName}.${details.fieldName}: ${details.reason} - ${details.message}`);
|
||||
this.name = 'TypeOrmAdminSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { AdminUserOrmEntity } from '../entities/AdminUserOrmEntity';
|
||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertStringArray,
|
||||
assertDate,
|
||||
assertOptionalDate,
|
||||
assertOptionalString,
|
||||
} from '../schema/TypeOrmAdminSchemaGuards';
|
||||
|
||||
export class AdminUserOrmMapper {
|
||||
toDomain(entity: AdminUserOrmEntity): AdminUser {
|
||||
const entityName = 'AdminUser';
|
||||
|
||||
try {
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'email', entity.email);
|
||||
assertNonEmptyString(entityName, 'displayName', entity.displayName);
|
||||
assertStringArray(entityName, 'roles', entity.roles);
|
||||
assertNonEmptyString(entityName, 'status', entity.status);
|
||||
assertOptionalString(entityName, 'primaryDriverId', entity.primaryDriverId);
|
||||
assertOptionalDate(entityName, 'lastLoginAt', entity.lastLoginAt);
|
||||
assertDate(entityName, 'createdAt', entity.createdAt);
|
||||
assertDate(entityName, 'updatedAt', entity.updatedAt);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeOrmAdminSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted AdminUser';
|
||||
throw new TypeOrmAdminSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
|
||||
try {
|
||||
const domainProps: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
primaryDriverId?: string;
|
||||
lastLoginAt?: Date;
|
||||
} = {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
displayName: entity.displayName,
|
||||
roles: entity.roles,
|
||||
status: entity.status,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
};
|
||||
|
||||
if (entity.primaryDriverId !== null && entity.primaryDriverId !== undefined) {
|
||||
domainProps.primaryDriverId = entity.primaryDriverId;
|
||||
}
|
||||
|
||||
if (entity.lastLoginAt !== null && entity.lastLoginAt !== undefined) {
|
||||
domainProps.lastLoginAt = entity.lastLoginAt;
|
||||
}
|
||||
|
||||
return AdminUser.create(domainProps);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted AdminUser';
|
||||
throw new TypeOrmAdminSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toOrmEntity(adminUser: AdminUser): AdminUserOrmEntity {
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
entity.id = adminUser.id.value;
|
||||
entity.email = adminUser.email.value;
|
||||
entity.displayName = adminUser.displayName;
|
||||
entity.roles = adminUser.roles.map(r => r.value);
|
||||
entity.status = adminUser.status.value;
|
||||
entity.createdAt = adminUser.createdAt;
|
||||
entity.updatedAt = adminUser.updatedAt;
|
||||
|
||||
if (adminUser.primaryDriverId !== undefined) {
|
||||
entity.primaryDriverId = adminUser.primaryDriverId;
|
||||
}
|
||||
|
||||
if (adminUser.lastLoginAt !== undefined) {
|
||||
entity.lastLoginAt = adminUser.lastLoginAt;
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toStored(entity: AdminUserOrmEntity): AdminUser {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import type { IAdminUserRepository, UserListQuery, UserListResult, UserFilter, StoredAdminUser } from '@core/admin/domain/repositories/IAdminUserRepository';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { AdminUserOrmEntity } from '../entities/AdminUserOrmEntity';
|
||||
import { AdminUserOrmMapper } from '../mappers/AdminUserOrmMapper';
|
||||
import { Email } from '@core/admin/domain/value-objects/Email';
|
||||
import { UserId } from '@core/admin/domain/value-objects/UserId';
|
||||
|
||||
export class TypeOrmAdminUserRepository implements IAdminUserRepository {
|
||||
private readonly repository: Repository<AdminUserOrmEntity>;
|
||||
private readonly mapper: AdminUserOrmMapper;
|
||||
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
mapper?: AdminUserOrmMapper,
|
||||
) {
|
||||
this.repository = dataSource.getRepository(AdminUserOrmEntity);
|
||||
this.mapper = mapper ?? new AdminUserOrmMapper();
|
||||
}
|
||||
|
||||
async save(user: AdminUser): Promise<void> {
|
||||
const entity = this.mapper.toOrmEntity(user);
|
||||
await this.repository.save(entity);
|
||||
}
|
||||
|
||||
async findById(id: UserId): Promise<AdminUser | null> {
|
||||
const entity = await this.repository.findOne({ where: { id: id.value } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByEmail(email: Email): Promise<AdminUser | null> {
|
||||
const entity = await this.repository.findOne({ where: { email: email.value } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async emailExists(email: Email): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { email: email.value } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async existsById(id: UserId): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { id: id.value } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async existsByEmail(email: Email): Promise<boolean> {
|
||||
return this.emailExists(email);
|
||||
}
|
||||
|
||||
async list(query?: UserListQuery): Promise<UserListResult> {
|
||||
const page = query?.pagination?.page ?? 1;
|
||||
const limit = query?.pagination?.limit ?? 10;
|
||||
const skip = (page - 1) * limit;
|
||||
const sortBy = query?.sort?.field ?? 'createdAt';
|
||||
const sortOrder = query?.sort?.direction ?? 'desc';
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (query?.filter?.role) {
|
||||
where.roles = { $contains: [query.filter.role.value] };
|
||||
}
|
||||
|
||||
if (query?.filter?.status) {
|
||||
where.status = query.filter.status.value;
|
||||
}
|
||||
|
||||
if (query?.filter?.search) {
|
||||
where.email = this.repository.manager.connection
|
||||
.createQueryBuilder()
|
||||
.where('email ILIKE :search', { search: `%${query.filter.search}%` })
|
||||
.orWhere('displayName ILIKE :search', { search: `%${query.filter.search}%` })
|
||||
.getQuery();
|
||||
}
|
||||
|
||||
const [entities, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
order: { [sortBy]: sortOrder },
|
||||
});
|
||||
|
||||
const users = entities.map(entity => this.mapper.toDomain(entity));
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async count(filter?: UserFilter): Promise<number> {
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (filter?.role) {
|
||||
where.roles = { $contains: [filter.role.value] };
|
||||
}
|
||||
|
||||
if (filter?.status) {
|
||||
where.status = filter.status.value;
|
||||
}
|
||||
|
||||
if (filter?.search) {
|
||||
where.email = this.repository.manager.connection
|
||||
.createQueryBuilder()
|
||||
.where('email ILIKE :search', { search: `%${filter.search}%` })
|
||||
.orWhere('displayName ILIKE :search', { search: `%${filter.search}%` })
|
||||
.getQuery();
|
||||
}
|
||||
|
||||
return await this.repository.count({ where });
|
||||
}
|
||||
|
||||
async create(user: AdminUser): Promise<AdminUser> {
|
||||
const entity = this.mapper.toOrmEntity(user);
|
||||
const saved = await this.repository.save(entity);
|
||||
return this.mapper.toDomain(saved);
|
||||
}
|
||||
|
||||
async update(user: AdminUser): Promise<AdminUser> {
|
||||
const entity = this.mapper.toOrmEntity(user);
|
||||
const updated = await this.repository.save(entity);
|
||||
return this.mapper.toDomain(updated);
|
||||
}
|
||||
|
||||
async delete(id: UserId): Promise<void> {
|
||||
await this.repository.delete({ id: id.value });
|
||||
}
|
||||
|
||||
toStored(user: AdminUser): StoredAdminUser {
|
||||
const stored: StoredAdminUser = {
|
||||
id: user.id.value,
|
||||
email: user.email.value,
|
||||
roles: user.roles.map(r => r.value),
|
||||
status: user.status.value,
|
||||
displayName: user.displayName,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
|
||||
if (user.lastLoginAt !== undefined) {
|
||||
stored.lastLoginAt = user.lastLoginAt;
|
||||
}
|
||||
|
||||
if (user.primaryDriverId !== undefined) {
|
||||
stored.primaryDriverId = user.primaryDriverId;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
fromStored(stored: StoredAdminUser): AdminUser {
|
||||
const props: {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
displayName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
} = {
|
||||
id: stored.id,
|
||||
email: stored.email,
|
||||
roles: stored.roles,
|
||||
status: stored.status,
|
||||
displayName: stored.displayName,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
|
||||
if (stored.lastLoginAt !== undefined) {
|
||||
props.lastLoginAt = stored.lastLoginAt;
|
||||
}
|
||||
|
||||
if (stored.primaryDriverId !== undefined) {
|
||||
props.primaryDriverId = stored.primaryDriverId;
|
||||
}
|
||||
|
||||
return AdminUser.rehydrate(props);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
||||
|
||||
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new TypeOrmAdminSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'INVALID_STRING',
|
||||
message: `Field ${fieldName} must be a non-empty string`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStringArray(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
|
||||
throw new TypeOrmAdminSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'INVALID_STRING_ARRAY',
|
||||
message: `Field ${fieldName} must be an array of strings`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDate(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (!(value instanceof Date) || isNaN(value.getTime())) {
|
||||
throw new TypeOrmAdminSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'INVALID_DATE',
|
||||
message: `Field ${fieldName} must be a valid Date`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalDate(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
assertDate(entityName, fieldName, value);
|
||||
}
|
||||
|
||||
export function assertOptionalString(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmAdminSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'INVALID_OPTIONAL_STRING',
|
||||
message: `Field ${fieldName} must be a string or undefined`,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user