remove demo code
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
import { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress';
|
||||
import { UserId } from '@core/identity/domain/value-objects/UserId';
|
||||
import { User } from '@core/identity/domain/entities/User';
|
||||
import { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
|
||||
import { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { Email } from '@core/admin/domain/value-objects/Email';
|
||||
|
||||
export type DemoLoginInput = {
|
||||
role: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
|
||||
};
|
||||
|
||||
export type DemoLoginResult = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type DemoLoginErrorCode = 'DEMO_NOT_ALLOWED' | 'REPOSITORY_ERROR';
|
||||
|
||||
export type DemoLoginApplicationError = ApplicationErrorCode<DemoLoginErrorCode, { message: string }>;
|
||||
|
||||
/**
|
||||
* Application Use Case: DemoLoginUseCase
|
||||
*
|
||||
* Provides demo login functionality for development environments.
|
||||
* Creates demo users with predefined credentials.
|
||||
*
|
||||
* ⚠️ DEVELOPMENT ONLY - Should be disabled in production
|
||||
*/
|
||||
export class DemoLoginUseCase implements UseCase<DemoLoginInput, void, DemoLoginErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<DemoLoginResult>,
|
||||
private readonly adminUserRepo?: IAdminUserRepository,
|
||||
) {}
|
||||
|
||||
async execute(input: DemoLoginInput): Promise<Result<void, DemoLoginApplicationError>> {
|
||||
// Security check: Only allow in development
|
||||
if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEMO_LOGIN !== 'true') {
|
||||
return Result.err({
|
||||
code: 'DEMO_NOT_ALLOWED',
|
||||
details: { message: 'Demo login is only available in development environment' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate demo user email and display name based on role
|
||||
const roleConfig = {
|
||||
'driver': { email: 'demo.driver@example.com', name: 'John Demo', primaryDriverId: true, adminRole: null },
|
||||
'sponsor': { email: 'demo.sponsor@example.com', name: 'Jane Sponsor', primaryDriverId: false, adminRole: null },
|
||||
'league-owner': { email: 'demo.owner@example.com', name: 'Alex Owner', primaryDriverId: true, adminRole: 'owner' },
|
||||
'league-steward': { email: 'demo.steward@example.com', name: 'Sam Steward', primaryDriverId: true, adminRole: 'admin' },
|
||||
'league-admin': { email: 'demo.admin@example.com', name: 'Taylor Admin', primaryDriverId: true, adminRole: 'admin' },
|
||||
'system-owner': { email: 'demo.systemowner@example.com', name: 'System Owner', primaryDriverId: true, adminRole: 'owner' },
|
||||
'super-admin': { email: 'demo.superadmin@example.com', name: 'Super Admin', primaryDriverId: true, adminRole: 'admin' },
|
||||
};
|
||||
|
||||
const config = roleConfig[input.role];
|
||||
const emailVO = EmailAddress.create(config.email);
|
||||
|
||||
// Check if demo user already exists
|
||||
let user = await this.authRepo.findByEmail(emailVO);
|
||||
|
||||
if (!user) {
|
||||
// Create new demo user
|
||||
this.logger.info('[DemoLoginUseCase] Creating new demo user', { role: input.role });
|
||||
|
||||
const userId = UserId.create();
|
||||
|
||||
// Use a fixed demo password and hash it
|
||||
const demoPassword = 'Demo1234!';
|
||||
const hashedPassword = await this.passwordService.hash(demoPassword);
|
||||
|
||||
// Import PasswordHash and create proper object
|
||||
const passwordHashModule = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const userProps: any = {
|
||||
id: userId,
|
||||
displayName: config.name,
|
||||
email: config.email,
|
||||
passwordHash,
|
||||
};
|
||||
|
||||
// Always set primaryDriverId for demo users to ensure dashboard works
|
||||
userProps.primaryDriverId = `demo-${input.role}-${userId.value}`;
|
||||
if (config.primaryDriverId) {
|
||||
// Add avatar URL for demo users with primary driver
|
||||
// Use the same format as seeded drivers: /media/default/neutral-default-avatar
|
||||
userProps.avatarUrl = '/media/default/neutral-default-avatar';
|
||||
}
|
||||
|
||||
user = User.create(userProps);
|
||||
|
||||
await this.authRepo.save(user);
|
||||
} else {
|
||||
this.logger.info('[DemoLoginUseCase] Using existing demo user', {
|
||||
role: input.role,
|
||||
userId: user.getId().value
|
||||
});
|
||||
}
|
||||
|
||||
// Also create admin user if this role requires admin access
|
||||
if (config.adminRole && this.adminUserRepo) {
|
||||
const existingAdmin = await this.adminUserRepo.findByEmail(Email.create(config.email));
|
||||
|
||||
if (!existingAdmin) {
|
||||
this.logger.info('[DemoLoginUseCase] Creating admin user for demo', { role: config.adminRole });
|
||||
|
||||
const adminProps: {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
displayName: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
} = {
|
||||
id: user.getId().value,
|
||||
email: config.email,
|
||||
displayName: config.name,
|
||||
roles: [config.adminRole],
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
};
|
||||
|
||||
const primaryDriverId = user.getPrimaryDriverId();
|
||||
if (primaryDriverId) {
|
||||
adminProps.primaryDriverId = primaryDriverId;
|
||||
}
|
||||
|
||||
const adminUser = AdminUser.create(adminProps);
|
||||
|
||||
await this.adminUserRepo.create(adminUser);
|
||||
}
|
||||
}
|
||||
|
||||
this.output.present({ user });
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: 'Failed to execute DemoLoginUseCase';
|
||||
|
||||
this.logger.error('DemoLoginUseCase.execute failed', error instanceof Error ? error : undefined, {
|
||||
input,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Controller, Get, Post, Body, Query, Inject, Res } from '@nestjs/common';
|
||||
import { Public } from './Public';
|
||||
import { AuthService } from './AuthService';
|
||||
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO, ForgotPasswordDTO, ResetPasswordDTO, DemoLoginDTO } from './dtos/AuthDto';
|
||||
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO, ForgotPasswordDTO, ResetPasswordDTO } from './dtos/AuthDto';
|
||||
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
||||
import type { Response } from 'express';
|
||||
// ProductionGuard will be added if needed - for now we'll use environment check directly
|
||||
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
@@ -58,13 +57,4 @@ export class AuthController {
|
||||
async resetPassword(@Body() params: ResetPasswordDTO): Promise<{ message: string }> {
|
||||
return this.authService.resetPassword(params);
|
||||
}
|
||||
|
||||
@Post('demo-login')
|
||||
async demoLogin(@Body() params: DemoLoginDTO): Promise<AuthSessionDTO> {
|
||||
// Manual production check
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('Demo login is not available in production');
|
||||
}
|
||||
return this.authService.demoLogin(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCas
|
||||
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
|
||||
import { ForgotPasswordUseCase } from '@core/identity/application/use-cases/ForgotPasswordUseCase';
|
||||
import { ResetPasswordUseCase } from '@core/identity/application/use-cases/ResetPasswordUseCase';
|
||||
import { DemoLoginUseCase } from '../../development/use-cases/DemoLoginUseCase';
|
||||
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
||||
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
|
||||
import type { IMagicLinkRepository } from '@core/identity/domain/repositories/IMagicLinkRepository';
|
||||
@@ -17,9 +16,7 @@ import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUs
|
||||
import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
|
||||
import type { ForgotPasswordResult } from '@core/identity/application/use-cases/ForgotPasswordUseCase';
|
||||
import type { ResetPasswordResult } from '@core/identity/application/use-cases/ResetPasswordUseCase';
|
||||
import type { DemoLoginResult } from '../../development/use-cases/DemoLoginUseCase';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
|
||||
|
||||
import {
|
||||
AUTH_REPOSITORY_TOKEN,
|
||||
@@ -27,13 +24,11 @@ import {
|
||||
USER_REPOSITORY_TOKEN,
|
||||
MAGIC_LINK_REPOSITORY_TOKEN,
|
||||
} from '../../persistence/identity/IdentityPersistenceTokens';
|
||||
import { ADMIN_USER_REPOSITORY_TOKEN } from '../../persistence/admin/AdminPersistenceTokens';
|
||||
|
||||
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
||||
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
||||
import { ForgotPasswordPresenter } from './presenters/ForgotPasswordPresenter';
|
||||
import { ResetPasswordPresenter } from './presenters/ResetPasswordPresenter';
|
||||
import { DemoLoginPresenter } from './presenters/DemoLoginPresenter';
|
||||
import { ConsoleMagicLinkNotificationAdapter } from '@adapters/notifications/ports/ConsoleMagicLinkNotificationAdapter';
|
||||
|
||||
// Define the tokens for dependency injection
|
||||
@@ -45,13 +40,11 @@ export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase';
|
||||
export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase';
|
||||
export const FORGOT_PASSWORD_USE_CASE_TOKEN = 'ForgotPasswordUseCase';
|
||||
export const RESET_PASSWORD_USE_CASE_TOKEN = 'ResetPasswordUseCase';
|
||||
export const DEMO_LOGIN_USE_CASE_TOKEN = 'DemoLoginUseCase';
|
||||
|
||||
export const AUTH_SESSION_OUTPUT_PORT_TOKEN = 'AuthSessionOutputPort';
|
||||
export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort';
|
||||
export const FORGOT_PASSWORD_OUTPUT_PORT_TOKEN = 'ForgotPasswordOutputPort';
|
||||
export const RESET_PASSWORD_OUTPUT_PORT_TOKEN = 'ResetPasswordOutputPort';
|
||||
export const DEMO_LOGIN_OUTPUT_PORT_TOKEN = 'DemoLoginOutputPort';
|
||||
export const MAGIC_LINK_NOTIFICATION_PORT_TOKEN = 'MagicLinkNotificationPort';
|
||||
|
||||
export const AuthProviders: Provider[] = [
|
||||
@@ -98,7 +91,6 @@ export const AuthProviders: Provider[] = [
|
||||
},
|
||||
ForgotPasswordPresenter,
|
||||
ResetPasswordPresenter,
|
||||
DemoLoginPresenter,
|
||||
{
|
||||
provide: FORGOT_PASSWORD_OUTPUT_PORT_TOKEN,
|
||||
useExisting: ForgotPasswordPresenter,
|
||||
@@ -107,10 +99,6 @@ export const AuthProviders: Provider[] = [
|
||||
provide: RESET_PASSWORD_OUTPUT_PORT_TOKEN,
|
||||
useExisting: ResetPasswordPresenter,
|
||||
},
|
||||
{
|
||||
provide: DEMO_LOGIN_OUTPUT_PORT_TOKEN,
|
||||
useExisting: DemoLoginPresenter,
|
||||
},
|
||||
{
|
||||
provide: MAGIC_LINK_NOTIFICATION_PORT_TOKEN,
|
||||
useFactory: (logger: Logger) => new ConsoleMagicLinkNotificationAdapter(logger),
|
||||
@@ -138,15 +126,4 @@ export const AuthProviders: Provider[] = [
|
||||
) => new ResetPasswordUseCase(authRepo, magicLinkRepo, passwordHashing, logger, output),
|
||||
inject: [AUTH_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, RESET_PASSWORD_OUTPUT_PORT_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: DEMO_LOGIN_USE_CASE_TOKEN,
|
||||
useFactory: (
|
||||
authRepo: IAuthRepository,
|
||||
passwordHashing: IPasswordHashingService,
|
||||
logger: Logger,
|
||||
output: UseCaseOutputPort<DemoLoginResult>,
|
||||
adminUserRepo: IAdminUserRepository,
|
||||
) => new DemoLoginUseCase(authRepo, passwordHashing, logger, output, adminUserRepo),
|
||||
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, DEMO_LOGIN_OUTPUT_PORT_TOKEN, ADMIN_USER_REPOSITORY_TOKEN],
|
||||
},
|
||||
];
|
||||
];
|
||||
@@ -42,16 +42,6 @@ class FakeResetPasswordPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDemoLoginPresenter {
|
||||
private model: any = null;
|
||||
reset() { this.model = null; }
|
||||
present(model: any) { this.model = model; }
|
||||
get responseModel() {
|
||||
if (!this.model) throw new Error('Presenter not presented');
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
describe('AuthService - New Methods', () => {
|
||||
describe('forgotPassword', () => {
|
||||
it('should execute forgot password use case and return result', async () => {
|
||||
@@ -71,12 +61,10 @@ describe('AuthService - New Methods', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
forgotPasswordUseCase as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
forgotPasswordPresenter as any,
|
||||
new FakeResetPasswordPresenter() as any,
|
||||
new FakeDemoLoginPresenter() as any,
|
||||
);
|
||||
|
||||
const result = await service.forgotPassword({ email: 'test@example.com' });
|
||||
@@ -97,12 +85,10 @@ describe('AuthService - New Methods', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn(async () => Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Too many attempts' } })) } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeForgotPasswordPresenter() as any,
|
||||
new FakeResetPasswordPresenter() as any,
|
||||
new FakeDemoLoginPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.forgotPassword({ email: 'test@example.com' })).rejects.toThrow('Too many attempts');
|
||||
@@ -127,12 +113,10 @@ describe('AuthService - New Methods', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
resetPasswordUseCase as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeForgotPasswordPresenter() as any,
|
||||
resetPasswordPresenter as any,
|
||||
new FakeDemoLoginPresenter() as any,
|
||||
);
|
||||
|
||||
const result = await service.resetPassword({
|
||||
@@ -148,6 +132,7 @@ describe('AuthService - New Methods', () => {
|
||||
});
|
||||
|
||||
it('should throw error on use case failure', async () => {
|
||||
const resetPasswordPresenter = new FakeResetPasswordPresenter();
|
||||
const service = new AuthService(
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
|
||||
@@ -156,12 +141,10 @@ describe('AuthService - New Methods', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn(async () => Result.err({ code: 'INVALID_TOKEN', details: { message: 'Invalid token' } })) } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeForgotPasswordPresenter() as any,
|
||||
new FakeResetPasswordPresenter() as any,
|
||||
new FakeDemoLoginPresenter() as any,
|
||||
resetPasswordPresenter as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -169,85 +152,4 @@ describe('AuthService - New Methods', () => {
|
||||
).rejects.toThrow('Invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('demoLogin', () => {
|
||||
it('should execute demo login use case and create session', async () => {
|
||||
const demoLoginPresenter = new FakeDemoLoginPresenter();
|
||||
const mockUser = {
|
||||
getId: () => ({ value: 'demo-user-123' }),
|
||||
getDisplayName: () => 'Alex Johnson',
|
||||
getEmail: () => 'demo.driver@example.com',
|
||||
getPrimaryDriverId: () => undefined,
|
||||
};
|
||||
|
||||
const demoLoginUseCase = {
|
||||
execute: vi.fn(async () => {
|
||||
demoLoginPresenter.present({ user: mockUser });
|
||||
return Result.ok(undefined);
|
||||
}),
|
||||
};
|
||||
|
||||
const identitySessionPort = {
|
||||
getCurrentSession: vi.fn(),
|
||||
createSession: vi.fn(async () => ({ token: 'demo-token-123' })),
|
||||
};
|
||||
|
||||
const service = new AuthService(
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
identitySessionPort as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
demoLoginUseCase as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeForgotPasswordPresenter() as any,
|
||||
new FakeResetPasswordPresenter() as any,
|
||||
demoLoginPresenter as any,
|
||||
);
|
||||
|
||||
const result = await service.demoLogin({ role: 'driver' });
|
||||
|
||||
expect(demoLoginUseCase.execute).toHaveBeenCalledWith({ role: 'driver' });
|
||||
expect(identitySessionPort.createSession).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'demo-user-123',
|
||||
displayName: 'Alex Johnson',
|
||||
email: 'demo.driver@example.com',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual({
|
||||
token: 'demo-token-123',
|
||||
user: {
|
||||
userId: 'demo-user-123',
|
||||
email: 'demo.driver@example.com',
|
||||
displayName: 'Alex Johnson',
|
||||
role: 'driver',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error on use case failure', async () => {
|
||||
const service = new AuthService(
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn(async () => Result.err({ code: 'DEMO_NOT_ALLOWED', details: { message: 'Demo not allowed' } })) } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeForgotPasswordPresenter() as any,
|
||||
new FakeResetPasswordPresenter() as any,
|
||||
new FakeDemoLoginPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.demoLogin({ role: 'driver' })).rejects.toThrow('Demo not allowed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,12 +40,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentSession()).resolves.toBeNull();
|
||||
@@ -66,12 +64,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.getCurrentSession()).resolves.toEqual({
|
||||
@@ -102,12 +98,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
authSessionPresenter as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
const session = await service.signupWithEmail({
|
||||
@@ -138,12 +132,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -173,12 +165,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
authSessionPresenter as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.loginWithEmail({ email: 'e3', password: 'p3' } as any)).resolves.toEqual({
|
||||
@@ -206,12 +196,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.loginWithEmail({ email: 'e', password: 'p' } as any)).rejects.toThrow('Bad login');
|
||||
@@ -226,12 +214,10 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.loginWithEmail({ email: 'e', password: 'p' } as any)).rejects.toThrow('Login failed');
|
||||
@@ -254,12 +240,10 @@ describe('AuthService', () => {
|
||||
logoutUseCase as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
commandResultPresenter as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.logout()).resolves.toEqual({ success: true });
|
||||
@@ -274,14 +258,12 @@ describe('AuthService', () => {
|
||||
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
{ execute: vi.fn() } as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeCommandResultPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
new FakeAuthSessionPresenter() as any,
|
||||
);
|
||||
|
||||
await expect(service.logout()).rejects.toThrow('Logout failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,6 @@ import {
|
||||
type ResetPasswordApplicationError,
|
||||
type ResetPasswordInput,
|
||||
} from '@core/identity/application/use-cases/ResetPasswordUseCase';
|
||||
import {
|
||||
DemoLoginUseCase,
|
||||
type DemoLoginApplicationError,
|
||||
type DemoLoginInput,
|
||||
} from '../../development/use-cases/DemoLoginUseCase';
|
||||
|
||||
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
||||
|
||||
@@ -36,7 +31,6 @@ import {
|
||||
COMMAND_RESULT_OUTPUT_PORT_TOKEN,
|
||||
FORGOT_PASSWORD_OUTPUT_PORT_TOKEN,
|
||||
RESET_PASSWORD_OUTPUT_PORT_TOKEN,
|
||||
DEMO_LOGIN_OUTPUT_PORT_TOKEN,
|
||||
IDENTITY_SESSION_PORT_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
LOGIN_USE_CASE_TOKEN,
|
||||
@@ -44,16 +38,14 @@ import {
|
||||
SIGNUP_USE_CASE_TOKEN,
|
||||
FORGOT_PASSWORD_USE_CASE_TOKEN,
|
||||
RESET_PASSWORD_USE_CASE_TOKEN,
|
||||
DEMO_LOGIN_USE_CASE_TOKEN,
|
||||
} from './AuthProviders';
|
||||
import type { AuthSessionDTO, AuthenticatedUserDTO } from './dtos/AuthDto';
|
||||
import type { AuthSessionDTO } from './dtos/AuthDto';
|
||||
import { LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto';
|
||||
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
||||
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
||||
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
||||
import { ForgotPasswordPresenter } from './presenters/ForgotPasswordPresenter';
|
||||
import { ResetPasswordPresenter } from './presenters/ResetPasswordPresenter';
|
||||
import { DemoLoginPresenter } from './presenters/DemoLoginPresenter';
|
||||
|
||||
function mapApplicationErrorToMessage(error: { details?: { message?: string } } | undefined, fallback: string): string {
|
||||
return error?.details?.message ?? fallback;
|
||||
@@ -69,7 +61,6 @@ export class AuthService {
|
||||
@Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase,
|
||||
@Inject(FORGOT_PASSWORD_USE_CASE_TOKEN) private readonly forgotPasswordUseCase: ForgotPasswordUseCase,
|
||||
@Inject(RESET_PASSWORD_USE_CASE_TOKEN) private readonly resetPasswordUseCase: ResetPasswordUseCase,
|
||||
@Inject(DEMO_LOGIN_USE_CASE_TOKEN) private readonly demoLoginUseCase: DemoLoginUseCase,
|
||||
// TODO presenters must not be injected
|
||||
@Inject(AUTH_SESSION_OUTPUT_PORT_TOKEN)
|
||||
private readonly authSessionPresenter: AuthSessionPresenter,
|
||||
@@ -79,8 +70,6 @@ export class AuthService {
|
||||
private readonly forgotPasswordPresenter: ForgotPasswordPresenter,
|
||||
@Inject(RESET_PASSWORD_OUTPUT_PORT_TOKEN)
|
||||
private readonly resetPasswordPresenter: ResetPasswordPresenter,
|
||||
@Inject(DEMO_LOGIN_OUTPUT_PORT_TOKEN)
|
||||
private readonly demoLoginPresenter: DemoLoginPresenter,
|
||||
) {}
|
||||
|
||||
async getCurrentSession(): Promise<AuthSessionDTO | null> {
|
||||
@@ -89,13 +78,16 @@ export class AuthService {
|
||||
const coreSession = await this.identitySessionPort.getCurrentSession();
|
||||
if (!coreSession) return null;
|
||||
|
||||
const userRole = coreSession.user.role;
|
||||
const role = userRole ? (userRole as AuthSessionDTO['user']['role']) : undefined;
|
||||
|
||||
return {
|
||||
token: coreSession.token,
|
||||
user: {
|
||||
userId: coreSession.user.id,
|
||||
email: coreSession.user.email ?? '',
|
||||
displayName: coreSession.user.displayName,
|
||||
role: coreSession.user.role as any,
|
||||
...(role !== undefined ? { role } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -275,57 +267,4 @@ export class AuthService {
|
||||
|
||||
return this.resetPasswordPresenter.responseModel;
|
||||
}
|
||||
|
||||
async demoLogin(params: { role: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin', rememberMe?: boolean }): Promise<AuthSessionDTO> {
|
||||
this.logger.debug(`[AuthService] Attempting demo login for role: ${params.role}`);
|
||||
|
||||
this.demoLoginPresenter.reset();
|
||||
|
||||
const input: DemoLoginInput = {
|
||||
role: params.role,
|
||||
};
|
||||
|
||||
const result = await this.demoLoginUseCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.unwrapErr() as DemoLoginApplicationError;
|
||||
throw new Error(mapApplicationErrorToMessage(error, 'Demo login failed'));
|
||||
}
|
||||
|
||||
const user = this.demoLoginPresenter.responseModel.user;
|
||||
const primaryDriverId = user.getPrimaryDriverId();
|
||||
|
||||
// Use primaryDriverId for session if available, otherwise fall back to userId
|
||||
const sessionId = primaryDriverId ?? user.getId().value;
|
||||
|
||||
const sessionOptions = params.rememberMe !== undefined
|
||||
? { rememberMe: params.rememberMe }
|
||||
: undefined;
|
||||
|
||||
const session = await this.identitySessionPort.createSession(
|
||||
{
|
||||
id: sessionId,
|
||||
displayName: user.getDisplayName(),
|
||||
email: user.getEmail() ?? '',
|
||||
role: params.role,
|
||||
},
|
||||
sessionOptions
|
||||
);
|
||||
|
||||
const userDTO: AuthenticatedUserDTO = {
|
||||
userId: user.getId().value,
|
||||
email: user.getEmail() ?? '',
|
||||
displayName: user.getDisplayName(),
|
||||
role: params.role,
|
||||
};
|
||||
|
||||
if (primaryDriverId !== undefined) {
|
||||
userDTO.primaryDriverId = primaryDriverId;
|
||||
}
|
||||
|
||||
return {
|
||||
token: session.token,
|
||||
user: userDTO,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ProductionGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const path = request.path;
|
||||
|
||||
// Block demo login in production
|
||||
if (path === '/auth/demo-login' || path === '/api/auth/demo-login') {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new ForbiddenException('Demo login is not available in production');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength, IsIn, IsOptional } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class AuthenticatedUserDTO {
|
||||
@ApiProperty()
|
||||
@@ -98,15 +98,4 @@ export class ResetPasswordDTO {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
export class DemoLoginDTO {
|
||||
@ApiProperty({ enum: ['driver', 'sponsor', 'league-owner', 'league-steward', 'league-admin', 'system-owner', 'super-admin'] })
|
||||
@IsString()
|
||||
@IsIn(['driver', 'sponsor', 'league-owner', 'league-steward', 'league-admin', 'system-owner', 'super-admin'])
|
||||
role!: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UseCaseOutputPort } from '@core/shared/application';
|
||||
import { DemoLoginResult } from '../../../development/use-cases/DemoLoginUseCase';
|
||||
|
||||
@Injectable()
|
||||
export class DemoLoginPresenter implements UseCaseOutputPort<DemoLoginResult> {
|
||||
private _responseModel: DemoLoginResult | null = null;
|
||||
|
||||
present(result: DemoLoginResult): void {
|
||||
this._responseModel = result;
|
||||
}
|
||||
|
||||
get responseModel(): DemoLoginResult {
|
||||
if (!this._responseModel) {
|
||||
throw new Error('DemoLoginPresenter: No response model available');
|
||||
}
|
||||
return this._responseModel;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._responseModel = null;
|
||||
}
|
||||
}
|
||||
@@ -50,18 +50,29 @@ describe('BootstrapModule Postgres racing seed gating (unit)', () => {
|
||||
return { SeedRacingData };
|
||||
});
|
||||
|
||||
// Mock SeedDemoUsers to avoid constructor issues
|
||||
vi.doMock('../../../../../adapters/bootstrap/SeedDemoUsers', () => {
|
||||
class SeedDemoUsers {
|
||||
execute = vi.fn(async () => undefined);
|
||||
}
|
||||
return { SeedDemoUsers };
|
||||
});
|
||||
|
||||
const { BootstrapModule } = await import('./BootstrapModule');
|
||||
|
||||
const ensureExecute = vi.fn(async () => undefined);
|
||||
|
||||
const leagueCountAll = vi.fn(async () => leaguesCount);
|
||||
|
||||
const seedDemoUsersExecute = vi.fn(async () => undefined);
|
||||
|
||||
const bootstrapModule = new BootstrapModule(
|
||||
{ execute: ensureExecute } as any,
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{
|
||||
leagueRepository: { countAll: leagueCountAll },
|
||||
} as any,
|
||||
{ execute: seedDemoUsersExecute } as any,
|
||||
);
|
||||
|
||||
await bootstrapModule.onModuleInit();
|
||||
|
||||
179
apps/api/src/domain/bootstrap/BootstrapModule.test.ts
Normal file
179
apps/api/src/domain/bootstrap/BootstrapModule.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type SetupOptions = {
|
||||
persistence: 'postgres' | 'inmemory';
|
||||
nodeEnv: string | undefined;
|
||||
bootstrapEnabled: boolean;
|
||||
leaguesCount: number;
|
||||
forceReseed?: boolean;
|
||||
};
|
||||
|
||||
describe('BootstrapModule demo user seed integration (unit)', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setup({
|
||||
persistence,
|
||||
nodeEnv,
|
||||
bootstrapEnabled,
|
||||
leaguesCount,
|
||||
forceReseed = false,
|
||||
}: SetupOptions): Promise<{
|
||||
seedRacingExecute: ReturnType<typeof vi.fn>;
|
||||
seedDemoUsersExecute: ReturnType<typeof vi.fn>;
|
||||
ensureExecute: ReturnType<typeof vi.fn>;
|
||||
leagueCountAll: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
process.env.NODE_ENV = nodeEnv;
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = persistence;
|
||||
process.env.GRIDPILOT_API_BOOTSTRAP = bootstrapEnabled ? 'true' : 'false';
|
||||
|
||||
if (forceReseed) {
|
||||
process.env.GRIDPILOT_API_FORCE_RESEED = 'true';
|
||||
} else {
|
||||
delete process.env.GRIDPILOT_API_FORCE_RESEED;
|
||||
}
|
||||
|
||||
vi.doMock('../../env', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../env')>('../../env');
|
||||
return {
|
||||
...actual,
|
||||
getApiPersistence: () => persistence,
|
||||
getEnableBootstrap: () => bootstrapEnabled,
|
||||
getForceReseed: () => forceReseed,
|
||||
};
|
||||
});
|
||||
|
||||
const seedRacingExecute = vi.fn(async () => undefined);
|
||||
const seedDemoUsersExecute = vi.fn(async () => undefined);
|
||||
|
||||
vi.doMock('../../../../../adapters/bootstrap/SeedRacingData', () => {
|
||||
class SeedRacingData {
|
||||
execute = seedRacingExecute;
|
||||
}
|
||||
return { SeedRacingData };
|
||||
});
|
||||
|
||||
vi.doMock('../../../../../adapters/bootstrap/SeedDemoUsers', () => {
|
||||
class SeedDemoUsers {
|
||||
execute = seedDemoUsersExecute;
|
||||
}
|
||||
return { SeedDemoUsers };
|
||||
});
|
||||
|
||||
const { BootstrapModule } = await import('./BootstrapModule');
|
||||
|
||||
const ensureExecute = vi.fn(async () => undefined);
|
||||
const leagueCountAll = vi.fn(async () => leaguesCount);
|
||||
|
||||
const bootstrapModule = new BootstrapModule(
|
||||
{ execute: ensureExecute } as any,
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{
|
||||
leagueRepository: { countAll: leagueCountAll },
|
||||
} as any,
|
||||
{ execute: seedDemoUsersExecute } as any,
|
||||
);
|
||||
|
||||
await bootstrapModule.onModuleInit();
|
||||
|
||||
return { seedRacingExecute, seedDemoUsersExecute, ensureExecute, leagueCountAll };
|
||||
}
|
||||
|
||||
it('seeds demo users when inmemory + bootstrap enabled', async () => {
|
||||
const { seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'inmemory',
|
||||
nodeEnv: 'test',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 123,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('seeds demo users when postgres + development + bootstrap enabled', async () => {
|
||||
const { seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'development',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 1,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not seed demo users when postgres + production', async () => {
|
||||
const { seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'production',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 0,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not seed demo users when bootstrap disabled', async () => {
|
||||
const { seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'inmemory',
|
||||
nodeEnv: 'test',
|
||||
bootstrapEnabled: false,
|
||||
leaguesCount: 0,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(0);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('seeds demo users with force reseed enabled', async () => {
|
||||
const { seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'development',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 1,
|
||||
forceReseed: true,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('seeds both racing data and demo users when both are needed', async () => {
|
||||
const { seedRacingExecute, seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'inmemory',
|
||||
nodeEnv: 'test',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 0,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedRacingExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips racing seed but seeds demo users when database has data', async () => {
|
||||
const { seedRacingExecute, seedDemoUsersExecute, ensureExecute } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'development',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 120,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(seedRacingExecute).toHaveBeenCalledTimes(0);
|
||||
expect(seedDemoUsersExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
|
||||
import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData';
|
||||
import { SeedDemoUsers } from '../../../../../adapters/bootstrap/SeedDemoUsers';
|
||||
import { Inject, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { getApiPersistence, getEnableBootstrap, getForceReseed } from '../../env';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule';
|
||||
import { AchievementPersistenceModule } from '../../persistence/achievement/AchievementPersistenceModule';
|
||||
import { IdentityPersistenceModule } from '../../persistence/identity/IdentityPersistenceModule';
|
||||
import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN } from './BootstrapProviders';
|
||||
import { AdminPersistenceModule } from '../../persistence/admin/AdminPersistenceModule';
|
||||
import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN, SEED_DEMO_USERS_TOKEN } from './BootstrapProviders';
|
||||
|
||||
@Module({
|
||||
imports: [RacingPersistenceModule, SocialPersistenceModule, AchievementPersistenceModule, IdentityPersistenceModule],
|
||||
imports: [RacingPersistenceModule, SocialPersistenceModule, AchievementPersistenceModule, IdentityPersistenceModule, AdminPersistenceModule],
|
||||
providers: BootstrapProviders,
|
||||
})
|
||||
export class BootstrapModule implements OnModuleInit {
|
||||
@@ -18,6 +20,7 @@ export class BootstrapModule implements OnModuleInit {
|
||||
@Inject(ENSURE_INITIAL_DATA_TOKEN) private readonly ensureInitialData: EnsureInitialData,
|
||||
@Inject('Logger') private readonly logger: Logger,
|
||||
@Inject('RacingSeedDependencies') private readonly seedDeps: RacingSeedDependencies,
|
||||
@Inject(SEED_DEMO_USERS_TOKEN) private readonly seedDemoUsers: SeedDemoUsers,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -34,6 +37,11 @@ export class BootstrapModule implements OnModuleInit {
|
||||
await new SeedRacingData(this.logger, this.seedDeps).execute();
|
||||
}
|
||||
|
||||
// Seed demo users (only in dev/test, respects bootstrap enable flag)
|
||||
if (await this.shouldSeedDemoUsers()) {
|
||||
await this.seedDemoUsers.execute();
|
||||
}
|
||||
|
||||
console.log('[Bootstrap] Application data initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[Bootstrap] Failed to initialize application data:', error);
|
||||
@@ -65,6 +73,31 @@ export class BootstrapModule implements OnModuleInit {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async shouldSeedDemoUsers(): Promise<boolean> {
|
||||
const persistence = getApiPersistence();
|
||||
|
||||
// Demo users are only seeded in dev/test environments
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Demo users can be seeded in both postgres and inmemory
|
||||
if (persistence === 'postgres' || persistence === 'inmemory') {
|
||||
// Check for force reseed flag
|
||||
const forceReseed = getForceReseed();
|
||||
if (forceReseed) {
|
||||
this.logger.info('[Bootstrap] Demo users force reseed enabled');
|
||||
return true;
|
||||
}
|
||||
|
||||
// The SeedDemoUsers class handles its own existence checks
|
||||
// We just need to determine if we should call it
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async isRacingDatabaseEmpty(): Promise<boolean> {
|
||||
const count = await this.seedDeps.leagueRepository.countAll?.();
|
||||
if (typeof count === 'number') return count === 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../
|
||||
import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../../persistence/achievement/AchievementPersistenceTokens';
|
||||
import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
|
||||
import type { RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData';
|
||||
import { SeedDemoUsers } from '../../../../../adapters/bootstrap/SeedDemoUsers';
|
||||
import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
|
||||
import {
|
||||
CreateAchievementUseCase,
|
||||
@@ -14,7 +15,8 @@ import type { IdentitySessionPort } from '@core/identity/application/ports/Ident
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { CookieIdentitySessionAdapter } from '../../../../../adapters/identity/session/CookieIdentitySessionAdapter';
|
||||
import { USER_REPOSITORY_TOKEN as IDENTITY_USER_REPOSITORY_TOKEN } from '../../persistence/identity/IdentityPersistenceTokens';
|
||||
import { USER_REPOSITORY_TOKEN as IDENTITY_USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN } from '../../persistence/identity/IdentityPersistenceTokens';
|
||||
import { ADMIN_USER_REPOSITORY_TOKEN } from '../../persistence/admin/AdminPersistenceTokens';
|
||||
|
||||
// Define tokens
|
||||
export const USER_REPOSITORY_TOKEN = 'IUserRepository_Bootstrap';
|
||||
@@ -25,6 +27,7 @@ export const CREATE_ACHIEVEMENT_USE_CASE_TOKEN = 'CreateAchievementUseCase_Boots
|
||||
|
||||
export const RACING_SEED_DEPENDENCIES_TOKEN = 'RacingSeedDependencies';
|
||||
export const ENSURE_INITIAL_DATA_TOKEN = 'EnsureInitialData_Bootstrap';
|
||||
export const SEED_DEMO_USERS_TOKEN = 'SeedDemoUsers';
|
||||
|
||||
// Adapter classes for output ports
|
||||
class SignupWithEmailOutputAdapter implements UseCaseOutputPort<SignupWithEmailResult> {
|
||||
@@ -68,6 +71,9 @@ export const BootstrapProviders: Provider[] = [
|
||||
driverStatsRepository: RacingSeedDependencies['driverStatsRepository'],
|
||||
teamStatsRepository: RacingSeedDependencies['teamStatsRepository'],
|
||||
mediaRepository: RacingSeedDependencies['mediaRepository'],
|
||||
authRepository: RacingSeedDependencies['authRepository'],
|
||||
passwordHashingService: RacingSeedDependencies['passwordHashingService'],
|
||||
adminUserRepository: RacingSeedDependencies['adminUserRepository'],
|
||||
): RacingSeedDependencies => ({
|
||||
driverRepository,
|
||||
leagueRepository,
|
||||
@@ -92,6 +98,9 @@ export const BootstrapProviders: Provider[] = [
|
||||
driverStatsRepository,
|
||||
teamStatsRepository,
|
||||
mediaRepository,
|
||||
authRepository,
|
||||
passwordHashingService,
|
||||
adminUserRepository,
|
||||
}),
|
||||
inject: [
|
||||
'IDriverRepository',
|
||||
@@ -117,6 +126,9 @@ export const BootstrapProviders: Provider[] = [
|
||||
'IDriverStatsRepository',
|
||||
'ITeamStatsRepository',
|
||||
'IMediaRepository',
|
||||
AUTH_REPOSITORY_TOKEN,
|
||||
PASSWORD_HASHING_SERVICE_TOKEN,
|
||||
ADMIN_USER_REPOSITORY_TOKEN,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -171,4 +183,14 @@ export const BootstrapProviders: Provider[] = [
|
||||
},
|
||||
inject: [SIGNUP_USE_CASE_TOKEN, CREATE_ACHIEVEMENT_USE_CASE_TOKEN, 'Logger'],
|
||||
},
|
||||
{
|
||||
provide: SEED_DEMO_USERS_TOKEN,
|
||||
useFactory: (
|
||||
logger: Logger,
|
||||
seedDeps: RacingSeedDependencies,
|
||||
) => {
|
||||
return new SeedDemoUsers(logger, seedDeps.authRepository, seedDeps.passwordHashingService, seedDeps.adminUserRepository);
|
||||
},
|
||||
inject: ['Logger', RACING_SEED_DEPENDENCIES_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -11,7 +11,6 @@ describe('DashboardService', () => {
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
useCase as any,
|
||||
presenter as any,
|
||||
{ getDriverAvatar: vi.fn(() => '/media/avatar/test') } as any,
|
||||
);
|
||||
|
||||
await expect(service.getDashboardOverview('d1')).resolves.toEqual({ feed: [] });
|
||||
@@ -23,7 +22,6 @@ describe('DashboardService', () => {
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
|
||||
{ getResponseModel: vi.fn() } as any,
|
||||
{ getDriverAvatar: vi.fn(() => '/media/avatar/test') } as any,
|
||||
);
|
||||
|
||||
await expect(service.getDashboardOverview('d1')).rejects.toThrow('Failed to get dashboard overview: boom');
|
||||
@@ -34,9 +32,8 @@ describe('DashboardService', () => {
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
|
||||
{ getResponseModel: vi.fn() } as any,
|
||||
{ getDriverAvatar: vi.fn(() => '/media/avatar/test') } as any,
|
||||
);
|
||||
|
||||
await expect(service.getDashboardOverview('d1')).rejects.toThrow('Failed to get dashboard overview: Unknown error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,11 @@ import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresen
|
||||
|
||||
// Core imports
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import type { ImageServicePort } from '@core/media/application/ports/ImageServicePort';
|
||||
|
||||
// Tokens (standalone to avoid circular imports)
|
||||
import {
|
||||
DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
|
||||
DASHBOARD_OVERVIEW_USE_CASE_TOKEN,
|
||||
IMAGE_SERVICE_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
} from './DashboardTokens';
|
||||
|
||||
@@ -21,27 +19,11 @@ export class DashboardService {
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase,
|
||||
@Inject(DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN) private readonly presenter: DashboardOverviewPresenter,
|
||||
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: ImageServicePort,
|
||||
) {}
|
||||
|
||||
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
|
||||
this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
|
||||
|
||||
// Check if this is a demo user
|
||||
const isDemoUser = driverId.startsWith('demo-driver-') ||
|
||||
driverId.startsWith('demo-sponsor-') ||
|
||||
driverId.startsWith('demo-league-owner-') ||
|
||||
driverId.startsWith('demo-league-steward-') ||
|
||||
driverId.startsWith('demo-league-admin-') ||
|
||||
driverId.startsWith('demo-system-owner-') ||
|
||||
driverId.startsWith('demo-super-admin-');
|
||||
|
||||
if (isDemoUser) {
|
||||
// Return mock dashboard data for demo users
|
||||
this.logger.info('[DashboardService] Returning mock data for demo user', { driverId });
|
||||
return await this.getMockDashboardData(driverId);
|
||||
}
|
||||
|
||||
const result = await this.dashboardOverviewUseCase.execute({ driverId });
|
||||
|
||||
if (result.isErr()) {
|
||||
@@ -52,185 +34,4 @@ export class DashboardService {
|
||||
|
||||
return this.presenter.getResponseModel();
|
||||
}
|
||||
|
||||
private async getMockDashboardData(driverId: string): Promise<DashboardOverviewDTO> {
|
||||
// Determine role from driverId prefix
|
||||
const isSponsor = driverId.startsWith('demo-sponsor-');
|
||||
const isLeagueOwner = driverId.startsWith('demo-league-owner-');
|
||||
const isLeagueSteward = driverId.startsWith('demo-league-steward-');
|
||||
const isLeagueAdmin = driverId.startsWith('demo-league-admin-');
|
||||
const isSystemOwner = driverId.startsWith('demo-system-owner-');
|
||||
const isSuperAdmin = driverId.startsWith('demo-super-admin-');
|
||||
|
||||
// Get avatar URL using the image service (same as real drivers)
|
||||
const avatarUrl = this.imageService.getDriverAvatar(driverId);
|
||||
|
||||
// Mock sponsor dashboard
|
||||
if (isSponsor) {
|
||||
return {
|
||||
currentDriver: null,
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 0,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 0,
|
||||
items: [],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Mock league admin/owner/steward dashboard (similar to driver but with more leagues)
|
||||
if (isLeagueOwner || isLeagueSteward || isLeagueAdmin) {
|
||||
const roleTitle = isLeagueOwner ? 'League Owner' : isLeagueSteward ? 'League Steward' : 'League Admin';
|
||||
return {
|
||||
currentDriver: {
|
||||
id: driverId,
|
||||
name: `Demo ${roleTitle}`,
|
||||
country: 'US',
|
||||
avatarUrl,
|
||||
rating: 1600,
|
||||
globalRank: 15,
|
||||
totalRaces: 8,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
consistency: 90,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 2,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'feed-1',
|
||||
type: 'league_update',
|
||||
headline: 'New league season starting',
|
||||
body: 'Your league "Demo League" is about to start a new season',
|
||||
timestamp: new Date().toISOString(),
|
||||
ctaLabel: 'View League',
|
||||
ctaHref: '/leagues',
|
||||
},
|
||||
],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Mock system owner dashboard (highest privileges)
|
||||
if (isSystemOwner) {
|
||||
return {
|
||||
currentDriver: {
|
||||
id: driverId,
|
||||
name: 'System Owner',
|
||||
country: 'US',
|
||||
avatarUrl,
|
||||
rating: 2000,
|
||||
globalRank: 1,
|
||||
totalRaces: 50,
|
||||
wins: 25,
|
||||
podiums: 40,
|
||||
consistency: 95,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 10,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 5,
|
||||
items: [
|
||||
{
|
||||
id: 'feed-1',
|
||||
type: 'system_alert',
|
||||
headline: 'System maintenance scheduled',
|
||||
body: 'Platform will undergo maintenance in 24 hours',
|
||||
timestamp: new Date().toISOString(),
|
||||
ctaLabel: 'View Details',
|
||||
ctaHref: '/admin/system',
|
||||
},
|
||||
],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Mock super admin dashboard (all access)
|
||||
if (isSuperAdmin) {
|
||||
return {
|
||||
currentDriver: {
|
||||
id: driverId,
|
||||
name: 'Super Admin',
|
||||
country: 'US',
|
||||
avatarUrl,
|
||||
rating: 1800,
|
||||
globalRank: 5,
|
||||
totalRaces: 30,
|
||||
wins: 15,
|
||||
podiums: 25,
|
||||
consistency: 92,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 5,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 3,
|
||||
items: [
|
||||
{
|
||||
id: 'feed-1',
|
||||
type: 'admin_notification',
|
||||
headline: 'Admin dashboard access granted',
|
||||
body: 'You have full administrative access to all platform features',
|
||||
timestamp: new Date().toISOString(),
|
||||
ctaLabel: 'Admin Panel',
|
||||
ctaHref: '/admin',
|
||||
},
|
||||
],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Mock driver dashboard (default)
|
||||
return {
|
||||
currentDriver: {
|
||||
id: driverId,
|
||||
name: 'John Demo',
|
||||
country: 'US',
|
||||
avatarUrl,
|
||||
rating: 1500,
|
||||
globalRank: 25,
|
||||
totalRaces: 5,
|
||||
wins: 2,
|
||||
podiums: 3,
|
||||
consistency: 85,
|
||||
},
|
||||
myUpcomingRaces: [],
|
||||
otherUpcomingRaces: [],
|
||||
upcomingRaces: [],
|
||||
activeLeaguesCount: 0,
|
||||
nextRace: null,
|
||||
recentResults: [],
|
||||
leagueStandingsSummaries: [],
|
||||
feedSummary: {
|
||||
notificationCount: 0,
|
||||
items: [],
|
||||
},
|
||||
friends: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Admin Persistence Module
|
||||
*
|
||||
* Abstract module interface for admin persistence.
|
||||
* Both InMemory and TypeORM implementations should export this.
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
import { getApiPersistence } from '../../env';
|
||||
import { InMemoryAdminPersistenceModule } from '../inmemory/InMemoryAdminPersistenceModule';
|
||||
import { PostgresAdminPersistenceModule } from '../postgres/PostgresAdminPersistenceModule';
|
||||
|
||||
const selectedPersistenceModule =
|
||||
getApiPersistence() === 'postgres' ? PostgresAdminPersistenceModule : InMemoryAdminPersistenceModule;
|
||||
|
||||
@Module({
|
||||
imports: [selectedPersistenceModule],
|
||||
exports: [selectedPersistenceModule],
|
||||
})
|
||||
export class AdminPersistenceModule {}
|
||||
Reference in New Issue
Block a user