Ajout de la gestion des erreurs HTTP, implémentation de la validation des comptes, et mise à jour des dépendances. Création de tests pour les entités et services de compte, ainsi que l'ajout d'un système de limitation de taux.
This commit is contained in:
1052
package-lock.json
generated
1052
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,20 +14,25 @@
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"lint": "oxlint .",
|
||||
"type": "tsc --noEmit",
|
||||
"build": "rolldown src/app.ts --file dist/app.js --platform node --format esm --minify"
|
||||
"build": "rolldown src/app.ts --file dist/app.js --platform node --format esm --minify",
|
||||
"tests": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prettier/plugin-oxc": "^0.0.4",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"oxlint": "^1.11.1",
|
||||
"prettier": "3.6.2",
|
||||
"rolldown": "^1.0.0-beta.32",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.18.1",
|
||||
"@hono/zod-validator": "^0.7.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"hono": "^4.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^4.0.17"
|
||||
|
@ -4,6 +4,7 @@ import loadConfiguration from "./config";
|
||||
import { DatabaseInterface } from "./database/DatabaseInterface";
|
||||
import PgDatabase from "./database/PgDatabase";
|
||||
import { setup as setupAccounts } from "./domain/account/setup";
|
||||
import { HTTPError } from "./errors";
|
||||
|
||||
const config = loadConfiguration();
|
||||
const database: DatabaseInterface = new PgDatabase(config.database);
|
||||
@ -11,6 +12,14 @@ const app = new Hono();
|
||||
|
||||
app.route("/accounts", setupAccounts(database));
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof HTTPError) {
|
||||
return c.json({ error: err.message }, err.statusCode);
|
||||
}
|
||||
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
});
|
||||
|
||||
serve({
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
|
@ -1,15 +1,40 @@
|
||||
import { Hono } from "hono";
|
||||
import { validator } from "hono/validator";
|
||||
import { AccountServiceInterface } from "../service/AccountServiceInterface";
|
||||
import { loginSchema, registerSchema } from "../validation/AccountValidation";
|
||||
import { BadSchemaError } from "../../../errors";
|
||||
import { createRateLimit } from "../../../middleware/rateLimiter";
|
||||
|
||||
const loginRateLimit = createRateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
maxAttempts: 5,
|
||||
keyGenerator: (c) => {
|
||||
const ip =
|
||||
c.req.header("x-forwarded-for") ||
|
||||
c.req.header("x-real-ip") ||
|
||||
"127.0.0.1";
|
||||
return `login:${ip}`;
|
||||
},
|
||||
});
|
||||
|
||||
export default function toRoutes(
|
||||
accountService: AccountServiceInterface,
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/login", async (ctx) => {
|
||||
try {
|
||||
const { email, password } = await ctx.req.json();
|
||||
app.post(
|
||||
"/login",
|
||||
loginRateLimit,
|
||||
validator("json", (value) => {
|
||||
const parsed = loginSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new BadSchemaError(parsed.error.message);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}),
|
||||
async (ctx) => {
|
||||
const { email, password } = ctx.req.valid("json");
|
||||
const account = await accountService.login(email, password);
|
||||
|
||||
return ctx.json({
|
||||
@ -17,38 +42,30 @@ export default function toRoutes(
|
||||
accountId: account.id,
|
||||
email: account.email,
|
||||
});
|
||||
} catch (error) {
|
||||
return ctx.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erreur inconnue",
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/register",
|
||||
validator("json", (value) => {
|
||||
const parsed = registerSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new BadSchemaError(parsed.error.message);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/register", async (ctx) => {
|
||||
try {
|
||||
const { email, password } = await ctx.req.json();
|
||||
|
||||
const account = await accountService.createAccount(email, password);
|
||||
return parsed.data;
|
||||
}),
|
||||
async (ctx) => {
|
||||
const { email, password } = ctx.req.valid("json");
|
||||
const account = await accountService.register(email, password);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
accountId: account.id,
|
||||
email: account.email,
|
||||
});
|
||||
} catch (error) {
|
||||
return ctx.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erreur inconnue",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
@ -1,3 +1,13 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import {
|
||||
InvalidEmailFormatError,
|
||||
WeakPasswordError,
|
||||
} from "../errors/AccountErrors";
|
||||
import {
|
||||
EMAIL_REGEX,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "../validation/AccountValidation";
|
||||
|
||||
export class AccountEntity {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
@ -8,34 +18,36 @@ export class AccountEntity {
|
||||
public readonly updatedAt: Date,
|
||||
) {
|
||||
this.validateEmail(email);
|
||||
this.validatePassword(password);
|
||||
}
|
||||
|
||||
// Logique métier : validation de l'email
|
||||
private validateEmail(email: string): void {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
throw new Error("Format d'email invalide");
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
throw new InvalidEmailFormatError(email);
|
||||
}
|
||||
}
|
||||
|
||||
private validatePassword(password: string): void {
|
||||
if (password.length < 8) {
|
||||
throw new Error("Mot de passe trop court");
|
||||
private static validatePassword(password: string): void {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new WeakPasswordError();
|
||||
}
|
||||
}
|
||||
|
||||
// Logique métier : vérification du mot de passe
|
||||
public verifyPassword(plainPassword: string): boolean {
|
||||
// Dans un vrai projet, on utiliserait bcrypt
|
||||
return this.password === plainPassword;
|
||||
return bcrypt.compareSync(plainPassword, this.password);
|
||||
}
|
||||
|
||||
public get hashedPassword(): string {
|
||||
return this.password;
|
||||
}
|
||||
|
||||
// Factory method pour créer un nouveau compte
|
||||
static create(email: string, password: string): AccountEntity {
|
||||
AccountEntity.validatePassword(password);
|
||||
|
||||
const now = new Date();
|
||||
const id = crypto.randomUUID();
|
||||
return new AccountEntity(id, email, password, 1, now, now);
|
||||
const hashedPassword = bcrypt.hashSync(password, 10);
|
||||
|
||||
return new AccountEntity(id, email, hashedPassword, 1, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
|
44
src/domain/account/errors/AccountErrors.ts
Normal file
44
src/domain/account/errors/AccountErrors.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { HTTPError } from "../../../errors";
|
||||
import { MIN_PASSWORD_LENGTH } from "../validation/AccountValidation";
|
||||
|
||||
export class AccountNotFoundError extends HTTPError {
|
||||
constructor(identifier: string) {
|
||||
super(404, `Compte non trouvé : ${identifier}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountAlreadyExistsError extends HTTPError {
|
||||
constructor(email: string) {
|
||||
super(409, `Un compte avec cet email existe déjà : ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class BadPasswordError extends HTTPError {
|
||||
constructor() {
|
||||
super(401, "Mot de passe incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidEmailFormatError extends HTTPError {
|
||||
constructor(email: string) {
|
||||
super(400, `Format d'email invalide : ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WeakPasswordError extends HTTPError {
|
||||
constructor() {
|
||||
super(
|
||||
400,
|
||||
`Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class TooManyAttemptsError extends HTTPError {
|
||||
constructor(retryAfter: number) {
|
||||
super(
|
||||
429,
|
||||
`Trop de tentatives de connexion. Réessayez dans ${retryAfter} secondes`,
|
||||
);
|
||||
}
|
||||
}
|
@ -2,6 +2,6 @@ import { AccountEntity } from "../entity/AccountEntity";
|
||||
|
||||
export interface AccountRepositoryInterface {
|
||||
findByEmail(email: string): Promise<AccountEntity | null>;
|
||||
save(account: AccountEntity): Promise<number>;
|
||||
save(account: AccountEntity): Promise<string>;
|
||||
findById(id: string): Promise<AccountEntity | null>;
|
||||
}
|
||||
|
@ -6,21 +6,86 @@ export default class AccountRepository implements AccountRepositoryInterface {
|
||||
constructor(private readonly database: DatabaseInterface) {}
|
||||
|
||||
async findByEmail(email: string): Promise<AccountEntity | null> {
|
||||
// Implémentation simple pour l'exemple
|
||||
// Dans un vrai projet, on ferait une requête à la base de données
|
||||
console.log(`Recherche du compte avec email: ${email}`);
|
||||
const sql = `
|
||||
SELECT id, email, password, role_id, created_at, updated_at
|
||||
FROM accounts
|
||||
WHERE email = $1
|
||||
`;
|
||||
|
||||
const result = await this.database.fetchOne<{
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role_id: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(sql, [email]);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async save(account: AccountEntity): Promise<number> {
|
||||
// Implémentation simple pour l'exemple
|
||||
console.log(`Sauvegarde du compte: ${account.id}`);
|
||||
return 1;
|
||||
return new AccountEntity(
|
||||
result.id,
|
||||
result.email,
|
||||
result.password,
|
||||
result.role_id,
|
||||
result.created_at,
|
||||
result.updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
async save(account: AccountEntity): Promise<string> {
|
||||
const sql = `
|
||||
INSERT INTO accounts (id, email, password, role_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password = EXCLUDED.password,
|
||||
role_id = EXCLUDED.role_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await this.database.fetchOne<{ id: string }>(sql, [
|
||||
account.id,
|
||||
account.email,
|
||||
account.hashedPassword,
|
||||
account.roleId,
|
||||
account.createdAt,
|
||||
account.updatedAt,
|
||||
]);
|
||||
|
||||
return result!.id;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AccountEntity | null> {
|
||||
// Implémentation simple pour l'exemple
|
||||
console.log(`Recherche du compte avec ID: ${id}`);
|
||||
const sql = `
|
||||
SELECT id, email, password, role_id, created_at, updated_at
|
||||
FROM accounts
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
const result = await this.database.fetchOne<{
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role_id: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(sql, [id]);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AccountEntity(
|
||||
result.id,
|
||||
result.email,
|
||||
result.password,
|
||||
result.role_id,
|
||||
result.created_at,
|
||||
result.updated_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +1,37 @@
|
||||
import { AccountEntity } from "../entity/AccountEntity";
|
||||
import { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
|
||||
import { AccountServiceInterface } from "./AccountServiceInterface";
|
||||
import {
|
||||
AccountNotFoundError,
|
||||
AccountAlreadyExistsError,
|
||||
BadPasswordError,
|
||||
} from "../errors/AccountErrors";
|
||||
|
||||
export default class AccountService implements AccountServiceInterface {
|
||||
constructor(private readonly accountRepository: AccountRepositoryInterface) {}
|
||||
|
||||
async login(email: string, password: string): Promise<AccountEntity> {
|
||||
// Logique métier DDD : authentification
|
||||
const account = await this.accountRepository.findByEmail(email);
|
||||
|
||||
if (!account) {
|
||||
throw new Error("Compte non trouvé");
|
||||
throw new AccountNotFoundError(email);
|
||||
}
|
||||
|
||||
if (!account.verifyPassword(password)) {
|
||||
throw new Error("Mot de passe incorrect");
|
||||
throw new BadPasswordError();
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
async createAccount(email: string, password: string): Promise<AccountEntity> {
|
||||
// Logique métier DDD : vérifier que l'email n'existe pas déjà
|
||||
async register(email: string, password: string): Promise<AccountEntity> {
|
||||
const existingAccount = await this.accountRepository.findByEmail(email);
|
||||
|
||||
if (existingAccount) {
|
||||
throw new Error("Un compte avec cet email existe déjà");
|
||||
throw new AccountAlreadyExistsError(email);
|
||||
}
|
||||
|
||||
// Utilisation de la factory method de l'entité
|
||||
const newAccount = AccountEntity.create(email, password);
|
||||
|
||||
await this.accountRepository.save(newAccount);
|
||||
|
||||
return newAccount;
|
||||
|
@ -2,5 +2,5 @@ import { AccountEntity } from "../entity/AccountEntity";
|
||||
|
||||
export interface AccountServiceInterface {
|
||||
login(email: string, password: string): Promise<AccountEntity>;
|
||||
createAccount(email: string, password: string): Promise<AccountEntity>;
|
||||
register(email: string, password: string): Promise<AccountEntity>;
|
||||
}
|
||||
|
25
src/domain/account/validation/AccountValidation.ts
Normal file
25
src/domain/account/validation/AccountValidation.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.regex(EMAIL_REGEX, "Format d'email invalide");
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(
|
||||
MIN_PASSWORD_LENGTH,
|
||||
`Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`,
|
||||
);
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
});
|
17
src/errors.ts
Normal file
17
src/errors.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
|
||||
export class HTTPError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: ContentfulStatusCode,
|
||||
message: string,
|
||||
parent?: Error,
|
||||
) {
|
||||
super(message, { cause: parent });
|
||||
}
|
||||
}
|
||||
|
||||
export class BadSchemaError extends HTTPError {
|
||||
constructor(message: string, parent?: Error) {
|
||||
super(400, message, parent);
|
||||
}
|
||||
}
|
57
src/middleware/rateLimiter.ts
Normal file
57
src/middleware/rateLimiter.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { TooManyAttemptsError } from '../domain/account/errors/AccountErrors';
|
||||
|
||||
interface RateLimitConfig {
|
||||
windowMs: number;
|
||||
maxAttempts: number;
|
||||
keyGenerator?: (c: Context) => string;
|
||||
}
|
||||
|
||||
interface AttemptRecord {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
const attempts = new Map<string, AttemptRecord>();
|
||||
|
||||
function cleanupExpiredEntries(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of attempts.entries()) {
|
||||
if (now > record.resetTime) {
|
||||
attempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRateLimit(config: RateLimitConfig) {
|
||||
const { windowMs, maxAttempts, keyGenerator = (c) => c.req.header('x-forwarded-for') || '127.0.0.1' } = config;
|
||||
|
||||
return async (c: Context, next: Next) => {
|
||||
const key = keyGenerator(c);
|
||||
const now = Date.now();
|
||||
|
||||
cleanupExpiredEntries();
|
||||
|
||||
const record = attempts.get(key);
|
||||
|
||||
if (!record) {
|
||||
attempts.set(key, { count: 1, resetTime: now + windowMs });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (now > record.resetTime) {
|
||||
attempts.set(key, { count: 1, resetTime: now + windowMs });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.count >= maxAttempts) {
|
||||
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
|
||||
throw new TooManyAttemptsError(retryAfter);
|
||||
}
|
||||
|
||||
record.count++;
|
||||
await next();
|
||||
};
|
||||
}
|
56
tests/AccountEntity.test.ts
Normal file
56
tests/AccountEntity.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
|
||||
import {
|
||||
InvalidEmailFormatError,
|
||||
WeakPasswordError,
|
||||
} from "../src/domain/account/errors/AccountErrors";
|
||||
import { MIN_PASSWORD_LENGTH } from "../src/domain/account/validation/AccountValidation";
|
||||
|
||||
describe("AccountEntity", () => {
|
||||
describe("create", () => {
|
||||
it("should create an account with valid email and password", () => {
|
||||
const email = "test@example.com";
|
||||
const password = "a".repeat(MIN_PASSWORD_LENGTH); // Use minimum length
|
||||
|
||||
const account = AccountEntity.create(email, password);
|
||||
|
||||
expect(account.email).toBe(email);
|
||||
expect(account.roleId).toBe(1);
|
||||
expect(account.id).toBeDefined();
|
||||
expect(account.createdAt).toBeInstanceOf(Date);
|
||||
expect(account.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should throw InvalidEmailFormatError for invalid email", () => {
|
||||
const invalidEmail = "invalid-email";
|
||||
const password = "password123";
|
||||
|
||||
expect(() => {
|
||||
AccountEntity.create(invalidEmail, password);
|
||||
}).toThrow(InvalidEmailFormatError);
|
||||
});
|
||||
|
||||
it("should throw WeakPasswordError for short password", () => {
|
||||
const email = "test@example.com";
|
||||
const shortPassword = "a".repeat(MIN_PASSWORD_LENGTH - 1); // One less than minimum
|
||||
|
||||
expect(() => {
|
||||
AccountEntity.create(email, shortPassword);
|
||||
}).toThrow(WeakPasswordError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("should return true for correct password", () => {
|
||||
const account = AccountEntity.create("test@example.com", "password123");
|
||||
|
||||
expect(account.verifyPassword("password123")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for incorrect password", () => {
|
||||
const account = AccountEntity.create("test@example.com", "password123");
|
||||
|
||||
expect(account.verifyPassword("wrongpassword")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
116
tests/AccountRepository.test.ts
Normal file
116
tests/AccountRepository.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import AccountRepository from "../src/domain/account/repository/AccoutRepository";
|
||||
import { DatabaseInterface } from "../src/database/DatabaseInterface";
|
||||
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
|
||||
|
||||
describe("AccountRepository", () => {
|
||||
let accountRepository: AccountRepository;
|
||||
let mockDatabase: DatabaseInterface;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDatabase = {
|
||||
ping: vi.fn(),
|
||||
fetchAll: vi.fn(),
|
||||
fetchOne: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
accountRepository = new AccountRepository(mockDatabase);
|
||||
});
|
||||
|
||||
describe("findByEmail", () => {
|
||||
it("should return account when found", async () => {
|
||||
const email = "test@example.com";
|
||||
const mockResult = {
|
||||
id: "123",
|
||||
email: email,
|
||||
password: "hashedPassword",
|
||||
role_id: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(mockDatabase.fetchOne).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await accountRepository.findByEmail(email);
|
||||
|
||||
expect(result).toBeInstanceOf(AccountEntity);
|
||||
expect(result?.email).toBe(email);
|
||||
expect(mockDatabase.fetchOne).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"SELECT id, email, password, role_id, created_at, updated_at",
|
||||
),
|
||||
[email],
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null when account not found", async () => {
|
||||
const email = "nonexistent@example.com";
|
||||
|
||||
vi.mocked(mockDatabase.fetchOne).mockResolvedValue(undefined);
|
||||
|
||||
const result = await accountRepository.findByEmail(email);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("should return account when found", async () => {
|
||||
const id = "123";
|
||||
const mockResult = {
|
||||
id: id,
|
||||
email: "test@example.com",
|
||||
password: "hashedPassword",
|
||||
role_id: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(mockDatabase.fetchOne).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await accountRepository.findById(id);
|
||||
|
||||
expect(result).toBeInstanceOf(AccountEntity);
|
||||
expect(result?.id).toBe(id);
|
||||
expect(mockDatabase.fetchOne).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"SELECT id, email, password, role_id, created_at, updated_at",
|
||||
),
|
||||
[id],
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null when account not found", async () => {
|
||||
const id = "nonexistent";
|
||||
|
||||
vi.mocked(mockDatabase.fetchOne).mockResolvedValue(undefined);
|
||||
|
||||
const result = await accountRepository.findById(id);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("should save account successfully", async () => {
|
||||
const account = AccountEntity.create("test@example.com", "password123");
|
||||
|
||||
vi.mocked(mockDatabase.fetchOne).mockResolvedValue({ id: account.id });
|
||||
|
||||
const result = await accountRepository.save(account);
|
||||
|
||||
expect(result).toBe(account.id);
|
||||
expect(mockDatabase.fetchOne).toHaveBeenCalledWith(
|
||||
expect.stringContaining("INSERT INTO accounts"),
|
||||
expect.arrayContaining([
|
||||
account.id,
|
||||
account.email,
|
||||
expect.any(String),
|
||||
account.roleId,
|
||||
account.createdAt,
|
||||
account.updatedAt,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
95
tests/AccountService.test.ts
Normal file
95
tests/AccountService.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import AccountService from "../src/domain/account/service/AccountService";
|
||||
import { AccountRepositoryInterface } from "../src/domain/account/repository/AccountRepositoryInterface";
|
||||
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
|
||||
import {
|
||||
AccountNotFoundError,
|
||||
AccountAlreadyExistsError,
|
||||
BadPasswordError,
|
||||
} from "../src/domain/account/errors/AccountErrors";
|
||||
|
||||
describe("AccountService", () => {
|
||||
let accountService: AccountService;
|
||||
let mockAccountRepository: AccountRepositoryInterface;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAccountRepository = {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
};
|
||||
accountService = new AccountService(mockAccountRepository);
|
||||
});
|
||||
|
||||
describe("createAccount", () => {
|
||||
it("should create a new account successfully", async () => {
|
||||
const email = "test@example.com";
|
||||
const password = "password123";
|
||||
|
||||
vi.mocked(mockAccountRepository.findByEmail).mockResolvedValue(null);
|
||||
vi.mocked(mockAccountRepository.save).mockResolvedValue("123");
|
||||
|
||||
const result = await accountService.register(email, password);
|
||||
|
||||
expect(result).toBeInstanceOf(AccountEntity);
|
||||
expect(result.email).toBe(email);
|
||||
expect(mockAccountRepository.findByEmail).toHaveBeenCalledWith(email);
|
||||
expect(mockAccountRepository.save).toHaveBeenCalledWith(
|
||||
expect.any(AccountEntity),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if account already exists", async () => {
|
||||
const email = "test@example.com";
|
||||
const password = "password123";
|
||||
const existingAccount = AccountEntity.create(email, password);
|
||||
|
||||
vi.mocked(mockAccountRepository.findByEmail).mockResolvedValue(
|
||||
existingAccount,
|
||||
);
|
||||
|
||||
await expect(accountService.register(email, password)).rejects.toThrow(
|
||||
AccountAlreadyExistsError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("should login successfully with correct credentials", async () => {
|
||||
const email = "test@example.com";
|
||||
const password = "password123";
|
||||
const account = AccountEntity.create(email, password);
|
||||
|
||||
vi.mocked(mockAccountRepository.findByEmail).mockResolvedValue(account);
|
||||
|
||||
const result = await accountService.login(email, password);
|
||||
|
||||
expect(result).toBe(account);
|
||||
expect(mockAccountRepository.findByEmail).toHaveBeenCalledWith(email);
|
||||
});
|
||||
|
||||
it("should throw error if account not found", async () => {
|
||||
const email = "test@example.com";
|
||||
const password = "password123";
|
||||
|
||||
vi.mocked(mockAccountRepository.findByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(accountService.login(email, password)).rejects.toThrow(
|
||||
AccountNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if password is incorrect", async () => {
|
||||
const email = "test@example.com";
|
||||
const password = "password123";
|
||||
const wrongPassword = "wrongpassword";
|
||||
const account = AccountEntity.create(email, password);
|
||||
|
||||
vi.mocked(mockAccountRepository.findByEmail).mockResolvedValue(account);
|
||||
|
||||
await expect(accountService.login(email, wrongPassword)).rejects.toThrow(
|
||||
BadPasswordError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
132
tests/AccountValidation.test.ts
Normal file
132
tests/AccountValidation.test.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
emailSchema,
|
||||
passwordSchema,
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
EMAIL_REGEX,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "../src/domain/account/validation/AccountValidation";
|
||||
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
|
||||
import {
|
||||
InvalidEmailFormatError,
|
||||
WeakPasswordError,
|
||||
} from "../src/domain/account/errors/AccountErrors";
|
||||
|
||||
describe("AccountValidation", () => {
|
||||
describe("Email validation consistency", () => {
|
||||
const validEmails = [
|
||||
"test@example.com",
|
||||
"user.name@domain.co.uk",
|
||||
"firstname+lastname@example.org",
|
||||
];
|
||||
|
||||
const invalidEmails = [
|
||||
"invalid-email",
|
||||
"@example.com",
|
||||
"test@",
|
||||
"test",
|
||||
"test@domain",
|
||||
"",
|
||||
];
|
||||
|
||||
it("should validate same emails in Zod and Entity", () => {
|
||||
validEmails.forEach((email) => {
|
||||
expect(emailSchema.safeParse(email).success).toBe(true);
|
||||
expect(() => AccountEntity.create(email, "password123")).not.toThrow(
|
||||
InvalidEmailFormatError,
|
||||
);
|
||||
});
|
||||
|
||||
invalidEmails.forEach((email) => {
|
||||
expect(emailSchema.safeParse(email).success).toBe(false);
|
||||
expect(() => AccountEntity.create(email, "password123")).toThrow(
|
||||
InvalidEmailFormatError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should use same regex pattern", () => {
|
||||
validEmails.forEach((email) => {
|
||||
expect(EMAIL_REGEX.test(email)).toBe(true);
|
||||
});
|
||||
|
||||
invalidEmails.forEach((email) => {
|
||||
expect(EMAIL_REGEX.test(email)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password validation consistency", () => {
|
||||
const validPasswords = ["password123", "abcdefgh", "12345678", "P@ssw0rd!"];
|
||||
|
||||
const invalidPasswords = ["1234567", "short", "", "abc"];
|
||||
|
||||
it("should validate same passwords in Zod and Entity", () => {
|
||||
validPasswords.forEach((password) => {
|
||||
expect(passwordSchema.safeParse(password).success).toBe(true);
|
||||
expect(() =>
|
||||
AccountEntity.create("test@example.com", password),
|
||||
).not.toThrow(WeakPasswordError);
|
||||
});
|
||||
|
||||
invalidPasswords.forEach((password) => {
|
||||
expect(passwordSchema.safeParse(password).success).toBe(false);
|
||||
expect(() =>
|
||||
AccountEntity.create("test@example.com", password),
|
||||
).toThrow(WeakPasswordError);
|
||||
});
|
||||
});
|
||||
|
||||
it("should use same minimum length", () => {
|
||||
validPasswords.forEach((password) => {
|
||||
expect(password.length >= MIN_PASSWORD_LENGTH).toBe(true);
|
||||
});
|
||||
|
||||
invalidPasswords.forEach((password) => {
|
||||
expect(password.length >= MIN_PASSWORD_LENGTH).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complete schemas", () => {
|
||||
it("should validate login schema correctly", () => {
|
||||
const validLogin = { email: "test@example.com", password: "password123" };
|
||||
const invalidLogin = { email: "invalid", password: "123" };
|
||||
|
||||
expect(loginSchema.safeParse(validLogin).success).toBe(true);
|
||||
expect(loginSchema.safeParse(invalidLogin).success).toBe(false);
|
||||
});
|
||||
|
||||
it("should validate register schema correctly", () => {
|
||||
const validRegister = {
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
};
|
||||
const invalidRegister = { email: "invalid", password: "123" };
|
||||
|
||||
expect(registerSchema.safeParse(validRegister).success).toBe(true);
|
||||
expect(registerSchema.safeParse(invalidRegister).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error messages consistency", () => {
|
||||
it("should return consistent password error message", () => {
|
||||
const result = passwordSchema.safeParse("123");
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain(
|
||||
`${MIN_PASSWORD_LENGTH} caractères`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return consistent email error message", () => {
|
||||
const result = emailSchema.safeParse("invalid-email");
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toBe("Format d'email invalide");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user