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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user