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:
@@ -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
17
src/bin/migrate.ts
Normal 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"));
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
29
src/database/Migration.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user