feat: add accounts and movies tables in migrations

- Created migration for accounts table with fields: id, username, password, role_id, created_at, updated_at.
- Created migration for movies table with fields: id, title, overview, poster_path, backdrop_path, release_date, tmdb_id.

refactor: update package.json scripts and dependencies

- Changed dev script to use bun instead of tsx.
- Added build:migrate script for migration.
- Updated devDependencies for bun and oxlint.

fix: refactor database connection and migration execution

- Updated PgDatabase to use SQL from bun.
- Refactored migration execution logic to read SQL files and execute them.

feat: implement account creation and validation logic

- Updated AccountEntity to use username instead of email.
- Added validation for username format and password strength.
- Implemented account repository methods for finding by username and inserting accounts.

test: add tests for account entity, repository, and service

- Created tests for AccountEntity to validate username and password.
- Added tests for AccountRepository to ensure correct database interactions.
- Implemented tests for AccountService to validate registration and login logic.

chore: remove outdated tests and files

- Deleted old tests related to email-based account management.
- Cleaned up unused imports and files to streamline the codebase.
This commit is contained in:
2025-10-02 20:54:41 +00:00
parent 91c14f750e
commit aaa0ca5a54
28 changed files with 617 additions and 654 deletions

View File

@@ -6,7 +6,7 @@ import { setup as setupAccounts } from "./domain/account/setup";
import { HTTPError } from "./errors";
const config = loadConfiguration();
const database: DatabaseInterface = new PgDatabase(config.database);
const database: DatabaseInterface = PgDatabase.fromOptions(config.database);
const app = new Hono();
app.route("/accounts", setupAccounts(database));

17
src/bin/migrate.ts Normal file
View File

@@ -0,0 +1,17 @@
import path from "node:path";
import Migration from "../database/Migration";
import PgDatabase from "../database/PgDatabase";
const connectionString = process.env.DATABASE_URL;
if (!connectionString)
throw new Error("DATABASE_URL environment variable is not set");
const migrationsFolder = process.env.MIGRATIONS_FOLDER;
if (!migrationsFolder)
throw new Error("MIGRATIONS_FOLDER environment variable is not set");
const db = PgDatabase.fromConnectionString(connectionString);
await db.ping();
const migration = new Migration(db);
await migration.execute(path.resolve("migrations"));

View File

@@ -24,6 +24,19 @@ export default function loadConfiguration(): Configuration {
};
}
export function loadTestConfiguration(): Configuration {
return {
database: {
host: getUnsafeEnv("TEST_DATABASE_HOST"),
port: parseInt(getUnsafeEnv("TEST_DATABASE_PORT")),
user: getUnsafeEnv("TEST_DATABASE_USER"),
password: getUnsafeEnv("TEST_DATABASE_PASSWORD"),
database: getUnsafeEnv("TEST_DATABASE_NAME"),
},
port: 3000,
};
}
export function getEnvOrDefault(key: string, defaultValue: string): string {
const value = process.env[key];
if (value === undefined) {

View File

@@ -1,6 +1,6 @@
export interface DatabaseInterface {
ping(): Promise<void>;
fetchAll<T = any>(sql: string, params?: any[]): Promise<T[]>;
fetchOne<T = any>(sql: string, params?: any[]): Promise<T | undefined>;
execute(sql: string, params?: any[]): Promise<void>;
close(): Promise<void>;
sql<T = any>(template: TemplateStringsArray, ...values: any[]): Promise<T>;
exec(query: string): Promise<void>;
}

29
src/database/Migration.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Glob } from "bun";
import type { DatabaseInterface } from "./DatabaseInterface";
import path from "node:path";
export default class Migration {
private readonly database: DatabaseInterface;
constructor(database: DatabaseInterface) {
this.database = database;
}
async execute(folderPath: string) {
const files = await this.readFolder(folderPath);
const contents = await Promise.all(
files.map((file) => Bun.file(file).text()),
);
for (const content of contents) {
// eslint-disable-next-line no-await-in-loop
await this.database.exec(content);
}
}
private async readFolder(folderPath: string) {
const glob = new Glob(path.join(folderPath, "*.sql"));
const files = await Array.fromAsync(glob.scan());
return files;
}
}

View File

@@ -1,5 +1,5 @@
import { Pool } from "pg";
import { DatabaseInterface } from "./DatabaseInterface";
import { SQL } from "bun";
import type { DatabaseInterface } from "./DatabaseInterface";
export interface PgDatabaseOptions {
host: string;
@@ -10,35 +10,38 @@ export interface PgDatabaseOptions {
}
export default class PgDatabase implements DatabaseInterface {
private readonly pool: Pool;
private readonly _sql: SQL;
constructor(options: PgDatabaseOptions) {
this.pool = new Pool({
host: options.host,
port: options.port,
user: options.user,
password: options.password,
database: options.database,
});
private constructor(connectionString: string) {
this._sql = new SQL(connectionString);
}
public static fromOptions(options: PgDatabaseOptions): PgDatabase {
return new PgDatabase(
`postgresql://${options.user}:${options.password}@${options.host}:${options.port}/${options.database}`,
);
}
public static fromConnectionString(connectionString: string): PgDatabase {
return new PgDatabase(connectionString);
}
async close(): Promise<void> {
await this._sql.close();
}
async ping(): Promise<void> {
await this.pool.query("SELECT 1");
await this._sql`SELECT 1`;
}
async fetchAll<T = any>(sql: string, params?: any[]): Promise<T[]> {
const res = await this.pool.query(sql, params);
return res.rows;
async sql<T = any>(
template: TemplateStringsArray,
...values: any[]
): Promise<T> {
return await this._sql(template, ...values);
}
async fetchOne<T = any>(sql: string, params?: any[]): Promise<T | undefined> {
const res = await this.fetchAll<T>(sql, params);
return res[0];
}
async execute(sql: string, params?: any[]): Promise<void> {
await this.pool.query(sql, params);
async exec(query: string): Promise<void> {
await this._sql.unsafe(query);
}
}

View File

@@ -34,13 +34,13 @@ export default function toRoutes(
return parsed.data;
}),
async (ctx) => {
const { email, password } = ctx.req.valid("json");
const account = await accountService.login(email, password);
const { username, password } = ctx.req.valid("json");
const account = await accountService.login(username, password);
return ctx.json({
success: true,
accountId: account.id,
email: account.email,
username: account.username,
});
},
);
@@ -56,13 +56,13 @@ export default function toRoutes(
return parsed.data;
}),
async (ctx) => {
const { email, password } = ctx.req.valid("json");
const account = await accountService.register(email, password);
const { username, password } = ctx.req.valid("json");
const account = await accountService.register(username, password);
return ctx.json({
success: true,
accountId: account.id,
email: account.email,
username: account.username,
});
},
);

View File

@@ -1,30 +1,22 @@
import bcrypt from "bcrypt";
import {
InvalidEmailFormatError,
InvalidUsernameFormatError,
WeakPasswordError,
} from "../errors/AccountErrors";
import {
EMAIL_REGEX,
MAX_USERNAME_LENGTH,
MIN_PASSWORD_LENGTH,
MIN_USERNAME_LENGTH,
} from "../validation/AccountValidation";
export class AccountEntity {
constructor(
public readonly id: string,
public readonly email: string,
public readonly id: number,
public readonly username: string,
private password: string,
public readonly roleId: number,
public readonly createdAt: Date,
public readonly updatedAt: Date,
) {
this.validateEmail(email);
}
private validateEmail(email: string): void {
if (!EMAIL_REGEX.test(email)) {
throw new InvalidEmailFormatError(email);
}
}
) {}
private static validatePassword(password: string): void {
if (password.length < MIN_PASSWORD_LENGTH) {
@@ -32,26 +24,41 @@ export class AccountEntity {
}
}
public verifyPassword(plainPassword: string): boolean {
return bcrypt.compareSync(plainPassword, this.password);
private static validateUsername(username: string): void {
if (
username.length < MIN_USERNAME_LENGTH ||
username.length > MAX_USERNAME_LENGTH
) {
throw new InvalidUsernameFormatError(username);
}
}
public async verifyPassword(plainPassword: string): Promise<boolean> {
return await Bun.password.verify(plainPassword, this.password);
}
public get hashedPassword(): string {
return this.password;
}
static create(email: string, password: string): AccountEntity {
static async create(
username: string,
password: string,
): Promise<CreateAccountEntity> {
AccountEntity.validatePassword(password);
AccountEntity.validateUsername(username);
const now = new Date();
const id = crypto.randomUUID();
const hashedPassword = bcrypt.hashSync(password, 10);
const hashedPassword = await Bun.password.hash(password);
return new AccountEntity(id, email, hashedPassword, 1, now, now);
return {
username,
hashedPassword,
roleId: 1,
};
}
}
export type CreateAccountDto = {
email: string;
password: string;
};
export type CreateAccountEntity = Omit<
AccountEntity,
"id" | "verifyPassword" | "createdAt" | "updatedAt"
>;

View File

@@ -8,8 +8,8 @@ export class AccountNotFoundError extends HTTPError {
}
export class AccountAlreadyExistsError extends HTTPError {
constructor(email: string) {
super(409, `Un compte avec cet email existe déjà : ${email}`);
constructor(username: string) {
super(409, `Un compte avec cet username existe déjà : ${username}`);
}
}
@@ -19,9 +19,9 @@ export class BadPasswordError extends HTTPError {
}
}
export class InvalidEmailFormatError extends HTTPError {
constructor(email: string) {
super(400, `Format d'email invalide : ${email}`);
export class InvalidUsernameFormatError extends HTTPError {
constructor(username: string) {
super(400, `Format d'username invalide : ${username}`);
}
}

View File

@@ -1,7 +1,10 @@
import { AccountEntity } from "../entity/AccountEntity";
import {
AccountEntity,
type CreateAccountEntity,
} from "../entity/AccountEntity";
export interface AccountRepositoryInterface {
findByEmail(email: string): Promise<AccountEntity | null>;
save(account: AccountEntity): Promise<string>;
findById(id: string): Promise<AccountEntity | null>;
findByUsername(username: string): Promise<AccountEntity | null>;
insert(account: CreateAccountEntity): Promise<number>;
findById(id: number): Promise<AccountEntity | null>;
}

View File

@@ -1,33 +1,36 @@
import type { DatabaseInterface } from "../../../database/DatabaseInterface";
import { AccountEntity } from "../entity/AccountEntity";
import {
AccountEntity,
type CreateAccountEntity,
} from "../entity/AccountEntity";
import type { AccountRepositoryInterface } from "./AccountRepositoryInterface";
export default class AccountRepository implements AccountRepositoryInterface {
constructor(private readonly database: DatabaseInterface) {}
async findByEmail(email: string): Promise<AccountEntity | null> {
const sql = `
SELECT id, email, password, role_id, created_at, updated_at
FROM accounts
WHERE email = $1
async findByUsername(username: string): Promise<AccountEntity | null> {
const [result] = await this.database.sql<
{
id: number;
username: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}[]
>`
SELECT id, username, password, role_id, created_at, updated_at
FROM accounts
WHERE username = ${username}
`;
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.username,
result.password,
result.role_id,
result.created_at,
@@ -35,53 +38,39 @@ export default class AccountRepository implements AccountRepositoryInterface {
);
}
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
async insert(account: CreateAccountEntity): Promise<number> {
const [result] = await this.database.sql<{ id: number }[]>`
INSERT INTO accounts (username, password, role_id, created_at, updated_at)
VALUES (${account.username}, ${account.hashedPassword}, ${account.roleId}, NOW(), NOW())
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> {
const sql = `
SELECT id, email, password, role_id, created_at, updated_at
FROM accounts
WHERE id = $1
async findById(id: number): Promise<AccountEntity | null> {
const [result] = await this.database.sql<
{
id: number;
username: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}[]
>`
SELECT id, username, password, role_id, created_at, updated_at
FROM accounts
WHERE id = ${id}
`;
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.username,
result.password,
result.role_id,
result.created_at,

View File

@@ -10,30 +10,31 @@ import {
export default class AccountService implements AccountServiceInterface {
constructor(private readonly accountRepository: AccountRepositoryInterface) {}
async login(email: string, password: string): Promise<AccountEntity> {
const account = await this.accountRepository.findByEmail(email);
async login(username: string, password: string): Promise<AccountEntity> {
const account = await this.accountRepository.findByUsername(username);
if (!account) {
throw new AccountNotFoundError(email);
throw new AccountNotFoundError(username);
}
if (!account.verifyPassword(password)) {
if (!(await account.verifyPassword(password))) {
throw new BadPasswordError();
}
return account;
}
async register(email: string, password: string): Promise<AccountEntity> {
const existingAccount = await this.accountRepository.findByEmail(email);
async register(username: string, password: string): Promise<AccountEntity> {
const existingAccount =
await this.accountRepository.findByUsername(username);
if (existingAccount) {
throw new AccountAlreadyExistsError(email);
throw new AccountAlreadyExistsError(username);
}
const newAccount = AccountEntity.create(email, password);
await this.accountRepository.save(newAccount);
const newAccount = await AccountEntity.create(username, password);
const newId = await this.accountRepository.insert(newAccount);
return newAccount;
return (await this.accountRepository.findById(newId)) as AccountEntity;
}
}

View File

@@ -1,11 +1,19 @@
import { z } from "zod";
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const MIN_PASSWORD_LENGTH = 8;
export const MAX_USERNAME_LENGTH = 20;
export const MIN_USERNAME_LENGTH = 3;
export const emailSchema = z
export const usernameSchema = z
.string()
.regex(EMAIL_REGEX, "Format d'email invalide");
.min(
MIN_USERNAME_LENGTH,
`Le nom d'utilisateur doit contenir au moins ${MIN_USERNAME_LENGTH} caractères`,
)
.max(
MAX_USERNAME_LENGTH,
`Le nom d'utilisateur doit contenir au plus ${MAX_USERNAME_LENGTH} caractères`,
);
export const passwordSchema = z
.string()
@@ -15,11 +23,11 @@ export const passwordSchema = z
);
export const loginSchema = z.object({
email: emailSchema,
username: usernameSchema,
password: passwordSchema,
});
export const registerSchema = z.object({
email: emailSchema,
username: usernameSchema,
password: passwordSchema,
});

View File

@@ -1,5 +1,5 @@
import { Context, Next } from 'hono';
import { TooManyAttemptsError } from '../domain/account/errors/AccountErrors';
import type { Context, Next } from "hono";
import { TooManyAttemptsError } from "../domain/account/errors/AccountErrors";
interface RateLimitConfig {
windowMs: number;
@@ -24,34 +24,38 @@ function cleanupExpiredEntries(): void {
}
export function createRateLimit(config: RateLimitConfig) {
const { windowMs, maxAttempts, keyGenerator = (c) => c.req.header('x-forwarded-for') || '127.0.0.1' } = config;
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();
};
}
}