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:
2025-08-12 18:29:05 +00:00
parent 14e69e1f61
commit 3fe9fc7142
18 changed files with 1774 additions and 64 deletions

1052
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,20 +14,25 @@
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
"lint": "oxlint .", "lint": "oxlint .",
"type": "tsc --noEmit", "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": { "devDependencies": {
"@prettier/plugin-oxc": "^0.0.4", "@prettier/plugin-oxc": "^0.0.4",
"@types/bcrypt": "^6.0.0",
"@types/node": "^24.2.1", "@types/node": "^24.2.1",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"oxlint": "^1.11.1", "oxlint": "^1.11.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"rolldown": "^1.0.0-beta.32", "rolldown": "^1.0.0-beta.32",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.9.2" "typescript": "^5.9.2",
"vitest": "^3.2.4"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.18.1", "@hono/node-server": "^1.18.1",
"@hono/zod-validator": "^0.7.2",
"bcrypt": "^6.0.0",
"hono": "^4.9.0", "hono": "^4.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"zod": "^4.0.17" "zod": "^4.0.17"

View File

@ -4,6 +4,7 @@ import loadConfiguration from "./config";
import { DatabaseInterface } from "./database/DatabaseInterface"; import { DatabaseInterface } from "./database/DatabaseInterface";
import PgDatabase from "./database/PgDatabase"; import PgDatabase from "./database/PgDatabase";
import { setup as setupAccounts } from "./domain/account/setup"; import { setup as setupAccounts } from "./domain/account/setup";
import { HTTPError } from "./errors";
const config = loadConfiguration(); const config = loadConfiguration();
const database: DatabaseInterface = new PgDatabase(config.database); const database: DatabaseInterface = new PgDatabase(config.database);
@ -11,6 +12,14 @@ const app = new Hono();
app.route("/accounts", setupAccounts(database)); 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({ serve({
port: config.port, port: config.port,
fetch: app.fetch, fetch: app.fetch,

View File

@ -1,15 +1,40 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { validator } from "hono/validator";
import { AccountServiceInterface } from "../service/AccountServiceInterface"; 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( export default function toRoutes(
accountService: AccountServiceInterface, accountService: AccountServiceInterface,
): Hono { ): Hono {
const app = new Hono(); const app = new Hono();
app.post("/login", async (ctx) => { app.post(
try { "/login",
const { email, password } = await ctx.req.json(); 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); const account = await accountService.login(email, password);
return ctx.json({ return ctx.json({
@ -17,38 +42,30 @@ export default function toRoutes(
accountId: account.id, accountId: account.id,
email: account.email, email: account.email,
}); });
} catch (error) { },
return ctx.json( );
{
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
},
400,
);
}
});
app.post("/register", async (ctx) => { app.post(
try { "/register",
const { email, password } = await ctx.req.json(); validator("json", (value) => {
const parsed = registerSchema.safeParse(value);
if (!parsed.success) {
throw new BadSchemaError(parsed.error.message);
}
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({ return ctx.json({
success: true, success: true,
accountId: account.id, accountId: account.id,
email: account.email, email: account.email,
}); });
} catch (error) { },
return ctx.json( );
{
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
},
400,
);
}
});
return app; return app;
} }

View File

@ -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 { export class AccountEntity {
constructor( constructor(
public readonly id: string, public readonly id: string,
@ -8,34 +18,36 @@ export class AccountEntity {
public readonly updatedAt: Date, public readonly updatedAt: Date,
) { ) {
this.validateEmail(email); this.validateEmail(email);
this.validatePassword(password);
} }
// Logique métier : validation de l'email
private validateEmail(email: string): void { private validateEmail(email: string): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!EMAIL_REGEX.test(email)) {
if (!emailRegex.test(email)) { throw new InvalidEmailFormatError(email);
throw new Error("Format d'email invalide");
} }
} }
private validatePassword(password: string): void { private static validatePassword(password: string): void {
if (password.length < 8) { if (password.length < MIN_PASSWORD_LENGTH) {
throw new Error("Mot de passe trop court"); throw new WeakPasswordError();
} }
} }
// Logique métier : vérification du mot de passe
public verifyPassword(plainPassword: string): boolean { public verifyPassword(plainPassword: string): boolean {
// Dans un vrai projet, on utiliserait bcrypt return bcrypt.compareSync(plainPassword, this.password);
return this.password === plainPassword; }
public get hashedPassword(): string {
return this.password;
} }
// Factory method pour créer un nouveau compte
static create(email: string, password: string): AccountEntity { static create(email: string, password: string): AccountEntity {
AccountEntity.validatePassword(password);
const now = new Date(); const now = new Date();
const id = crypto.randomUUID(); 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);
} }
} }

View 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`,
);
}
}

View File

@ -2,6 +2,6 @@ import { AccountEntity } from "../entity/AccountEntity";
export interface AccountRepositoryInterface { export interface AccountRepositoryInterface {
findByEmail(email: string): Promise<AccountEntity | null>; findByEmail(email: string): Promise<AccountEntity | null>;
save(account: AccountEntity): Promise<number>; save(account: AccountEntity): Promise<string>;
findById(id: string): Promise<AccountEntity | null>; findById(id: string): Promise<AccountEntity | null>;
} }

View File

@ -6,21 +6,86 @@ export default class AccountRepository implements AccountRepositoryInterface {
constructor(private readonly database: DatabaseInterface) {} constructor(private readonly database: DatabaseInterface) {}
async findByEmail(email: string): Promise<AccountEntity | null> { async findByEmail(email: string): Promise<AccountEntity | null> {
// Implémentation simple pour l'exemple const sql = `
// Dans un vrai projet, on ferait une requête à la base de données SELECT id, email, password, role_id, created_at, updated_at
console.log(`Recherche du compte avec email: ${email}`); FROM accounts
return null; 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;
}
return new AccountEntity(
result.id,
result.email,
result.password,
result.role_id,
result.created_at,
result.updated_at,
);
} }
async save(account: AccountEntity): Promise<number> { async save(account: AccountEntity): Promise<string> {
// Implémentation simple pour l'exemple const sql = `
console.log(`Sauvegarde du compte: ${account.id}`); INSERT INTO accounts (id, email, password, role_id, created_at, updated_at)
return 1; 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> { async findById(id: string): Promise<AccountEntity | null> {
// Implémentation simple pour l'exemple const sql = `
console.log(`Recherche du compte avec ID: ${id}`); SELECT id, email, password, role_id, created_at, updated_at
return null; 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,
);
} }
} }

View File

@ -1,36 +1,37 @@
import { AccountEntity } from "../entity/AccountEntity"; import { AccountEntity } from "../entity/AccountEntity";
import { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface"; import { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
import { AccountServiceInterface } from "./AccountServiceInterface"; import { AccountServiceInterface } from "./AccountServiceInterface";
import {
AccountNotFoundError,
AccountAlreadyExistsError,
BadPasswordError,
} from "../errors/AccountErrors";
export default class AccountService implements AccountServiceInterface { export default class AccountService implements AccountServiceInterface {
constructor(private readonly accountRepository: AccountRepositoryInterface) {} constructor(private readonly accountRepository: AccountRepositoryInterface) {}
async login(email: string, password: string): Promise<AccountEntity> { async login(email: string, password: string): Promise<AccountEntity> {
// Logique métier DDD : authentification
const account = await this.accountRepository.findByEmail(email); const account = await this.accountRepository.findByEmail(email);
if (!account) { if (!account) {
throw new Error("Compte non trouvé"); throw new AccountNotFoundError(email);
} }
if (!account.verifyPassword(password)) { if (!account.verifyPassword(password)) {
throw new Error("Mot de passe incorrect"); throw new BadPasswordError();
} }
return account; return account;
} }
async createAccount(email: string, password: string): Promise<AccountEntity> { async register(email: string, password: string): Promise<AccountEntity> {
// Logique métier DDD : vérifier que l'email n'existe pas déjà
const existingAccount = await this.accountRepository.findByEmail(email); const existingAccount = await this.accountRepository.findByEmail(email);
if (existingAccount) { 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); const newAccount = AccountEntity.create(email, password);
await this.accountRepository.save(newAccount); await this.accountRepository.save(newAccount);
return newAccount; return newAccount;

View File

@ -2,5 +2,5 @@ import { AccountEntity } from "../entity/AccountEntity";
export interface AccountServiceInterface { export interface AccountServiceInterface {
login(email: string, password: string): Promise<AccountEntity>; login(email: string, password: string): Promise<AccountEntity>;
createAccount(email: string, password: string): Promise<AccountEntity>; register(email: string, password: string): Promise<AccountEntity>;
} }

View 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
View 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);
}
}

View 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();
};
}

View 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);
});
});
});

View 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,
]),
);
});
});
});

View 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,
);
});
});
});

View 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
View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
},
});