This commit is contained in:
David Arranz 2025-01-30 11:45:31 +01:00
parent 6649374ad8
commit 3568e7e438
31 changed files with 478 additions and 16 deletions

View File

@ -0,0 +1 @@
export * from "./database";

View File

@ -0,0 +1,114 @@
import { EmailAddress, PasswordHash, Username } from "contexts/auth/domain";
import { authUserRepository } from "contexts/auth/infraestructure/sequelize";
import { Result, UniqueID } from "contexts/common/domain";
export class AuthService {
/**
* 🔹 `registerUser`
* Registra un nuevo usuario en la base de datos bajo transacción.
*/
static async registerUser(
username: string,
email: string,
password: string
): Promise<Result<{ userId: string }, Error>> {
return await authUserRepository.executeTransaction(async (transaction) => {
const userIdResult = UniqueID.generateNewID();
const usernameResult = Username.create(username);
const emailResult = EmailAddress.create(email);
const passwordResult = await PasswordHash.create(password);
const combined = Result.combine([userIdResult, usernameResult, emailResult, passwordResult]);
if (combined.isError()) {
return Result.fail(combined.error);
}
// Verificar si el usuario ya existe
const userExists = await authUserRepository.userExists(
emailResult.data.getValue(),
transaction
);
if (userExists) {
return Result.fail(new Error("Email is already registered"));
}
const user = await authUserRepository.createUser(
{
id: userIdResult.data.getValue(),
username: usernameResult.data.getValue(),
email: emailResult.data.getValue(),
password: passwordResult.data.getValue(),
isActive: true,
},
transaction
);
return Result.ok({ userId: user.id });
});
}
/**
* 🔹 `login`
* Autentica un usuario y genera un token JWT bajo transacción.
*/
static async login(
email: string,
password: string
): Promise<Result<{ token: string; userId: string }, Error>> {
return await authUserRepository.executeTransaction(async (transaction) => {
const emailResult = EmailAddress.create(email);
if (emailResult.isError()) {
return Result.fail(emailResult.error);
}
const user = await authUserRepository.findByEmail(emailResult.data.getValue(), transaction);
if (user.isError()) {
return Result.fail(new Error("Invalid email or password"));
}
const isValidPassword = await user.data.validatePassword(password);
if (!isValidPassword) {
return Result.fail(new Error("Invalid email or password"));
}
const token = JwtHelper.generateToken({ userId: user.data.getUserID() });
return Result.ok({ token, userId: user.data.getUserID() });
});
}
/**
* 🔹 `selectCompany`
* Permite a un usuario seleccionar una empresa activa en la sesión bajo transacción.
*/
static async selectCompany(
userId: string,
companyId: string
): Promise<Result<{ message: string }, Error>> {
return await authUserRepository.executeTransaction(async (transaction) => {
const user = await authUserRepository.findById(userId, transaction);
if (user.isError()) {
return Result.fail(new Error("User not found"));
}
const isAssociated = await authUserRepository.isUserAssociatedWithCompany(
userId,
companyId,
transaction
);
if (!isAssociated) {
return Result.fail(new Error("User does not have access to this company"));
}
return Result.ok({ message: "Company selected successfully" });
});
}
/**
* 🔹 `logout`
* Simula el cierre de sesión de un usuario. No requiere transacción.
*/
static logout(): Result<{ message: string }, never> {
return Result.ok({ message: "Logged out successfully" });
}
}

View File

@ -0,0 +1 @@
export * from "./auth.service";

View File

@ -1,4 +1,4 @@
import { Result } from "@common/domain";
import { Result } from "contexts/common/domain";
import { EmailAddress, PasswordHash, Username, UserRoles } from "./value-objects";
export class AuthUser {

View File

@ -0,0 +1,2 @@
export * from "./auth-user.model";
export * from "./value-objects";

View File

@ -1,4 +1,4 @@
import { Result, ValueObject } from "@common/domain";
import { Result, ValueObject } from "contexts/common/domain";
import { z } from "zod";
const RoleSchema = z.enum(["Admin", "User", "Manager", "Editor"]);

View File

@ -1,4 +1,4 @@
import { Result, ValueObject } from "@common/domain";
import { Result, ValueObject } from "contexts/common/domain";
import { z } from "zod";
export class EmailAddress extends ValueObject<string | null> {

View File

@ -1,5 +1,5 @@
import { Result, ValueObject } from "@common/domain";
import bcrypt from "bcrypt";
import { Result, ValueObject } from "contexts/common/domain";
import { z } from "zod";
const PasswordSchema = z

View File

@ -1,4 +1,4 @@
import { Result, ValueObject } from "@common/domain";
import { Result, ValueObject } from "contexts/common/domain";
import { z } from "zod";
export class Username extends ValueObject<string> {

View File

@ -0,0 +1,72 @@
import { authUserRepository } from "./auth-user.repository";
describe("authUserRepository", () => {
beforeEach(() => {
// Resetear la base de datos antes de cada prueba
});
it("should create a user successfully", async () => {
const result = await authUserRepository.createUser({
id: "user-uuid",
username: "testUser",
email: "user@example.com",
password: "hashed-password",
isActive: true,
});
expect(result).toHaveProperty("id");
expect(result.email).toBe("user@example.com");
});
/*it("should find a user by email", async () => {
await authUserRepository.createUser({
id: "user-uuid",
username: "testUser",
email: "user@example.com",
password: "hashed-password",
isActive: true,
});
const user = await authUserRepository.findByEmail("user@example.com");
expect(user.isOk()).toBe(true);
expect(user.data?.getUserID()).toBe("user-uuid");
});
it("should return an error when user is not found", async () => {
const user = await authUserRepository.findByEmail("notfound@example.com");
expect(user.isError()).toBe(true);
expect(user.error.message).toBe("User not found");
});*/
it("should check if a user exists", async () => {
await authUserRepository.createUser({
id: "user-uuid",
username: "testUser",
email: "exists@example.com",
password: "hashed-password",
isActive: true,
});
expect(await authUserRepository.userExists("exists@example.com")).toBe(true);
expect(await authUserRepository.userExists("notfound@example.com")).toBe(false);
});
it("should count active users", async () => {
await authUserRepository.createUser({
id: "1",
username: "user1",
email: "user1@example.com",
password: "pass",
isActive: true,
});
await authUserRepository.createUser({
id: "2",
username: "user2",
email: "user2@example.com",
password: "pass",
isActive: false,
});
expect(await authUserRepository.countActiveUsers()).toBe(1);
});
});

View File

@ -0,0 +1,62 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model } from "sequelize";
import { sequelize } from "../../../../config/database";
export type AuthUserCreationAttributes = InferCreationAttributes<AuthUserModel>;
class AuthUserModel extends Model<
InferAttributes<AuthUserModel>,
InferCreationAttributes<AuthUserModel>
> {
public id!: string;
public username!: string;
public email!: string;
public password!: string;
public roles!: string[];
public isActive!: boolean;
}
AuthUserModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
allowNull: false,
},
username: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
roles: {
type: DataTypes.ARRAY(DataTypes.STRING),
allowNull: false,
defaultValue: [],
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
},
{
sequelize,
tableName: "users",
paranoid: true, // softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ name: "email_idx", fields: ["email"], unique: true }],
}
);
export { AuthUserModel };

View File

@ -0,0 +1,42 @@
import { SequelizeRepository } from "contexts/common/infraestructure";
import { Transaction } from "sequelize";
import { AuthUserModel } from "./auth-user.model";
class AuthUserRepository extends SequelizeRepository<AuthUserModel> {
constructor() {
super(AuthUserModel);
}
async createUser(
data: { id: string; username: string; email: string; password: string; isActive: boolean },
transaction?: Transaction
): Promise<AuthUserModel> {
return await this.create(data, transaction);
}
async findAllUsers(): Promise<AuthUserModel[]> {
return await this.findAll();
}
async isUserAssociatedWithCompany(
userId: string,
companyId: string,
transaction?: Transaction
): Promise<boolean> {
const association = await AuthUserModel.findOne({
where: { id: userId, companyId },
transaction,
});
return !!association;
}
async userExists(email: string, transaction?: Transaction): Promise<boolean> {
return await this.exists("email", email, transaction);
}
async countActiveUsers(transaction?: Transaction): Promise<number> {
return await this.count({ where: { isActive: true }, transaction });
}
}
export const authUserRepository = new AuthUserRepository();

View File

@ -0,0 +1,2 @@
export * from "./auth-user.model";
export * from "./auth-user.repository";

View File

@ -1,2 +1,3 @@
export * from "./result";
export * from "./unique-id";
export * from "./value-object";

View File

@ -0,0 +1,45 @@
import { Result } from "./result";
describe("Result", () => {
it("should create a successful result", () => {
const result = Result.ok("Success Data");
expect(result.isOk()).toBe(true);
expect(result.isError()).toBe(false);
expect(result.data).toBe("Success Data");
});
it("should create a failed result", () => {
const error = new Error("Test error");
const result = Result.fail(error);
expect(result.isOk()).toBe(false);
expect(result.isError()).toBe(true);
expect(result.error).toBe(error);
});
it("should getOrElse return default value if result is a failure", () => {
const error = new Error("Test error");
const result = Result.fail(error);
expect(result.getOrElse("Default")).toBe("Default");
});
it("should match execute correct function based on success or failure", () => {
const successResult = Result.ok("Success");
const failureResult = Result.fail(new Error("Failure"));
expect(
successResult.match(
(data) => `OK: ${data}`,
(error) => null
)
).toBe("OK: Success");
expect(
failureResult.match(
(data) => null,
(error) => `ERROR: ${error.message}`
)
).toBe("ERROR: Failure");
});
});

View File

@ -64,7 +64,7 @@ export class Result<T, E extends Error = Error> {
* 🔹 `getOrElse(defaultValue: T): T`
* Si el `Result` es un `ok`, devuelve `data`, de lo contrario, devuelve `defaultValue`.
*/
getOrElse(defaultValue: T): T {
getOrElse(defaultValue: any): T | any {
return this.isSuccess ? this.data : defaultValue;
}

View File

@ -0,0 +1,35 @@
import { UniqueID } from "./unique-id";
describe("UniqueID Value Object", () => {
it("should generate a new UUID using generateNewID()", () => {
const result = UniqueID.generateNewID();
expect(result.isOk()).toBe(true);
expect(result.data.getValue()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
it("should return an error for an invalid UUID", () => {
const result = UniqueID.create("invalid-uuid");
expect(result.isError()).toBe(true);
expect(result.error.message).toBe("Invalid UUID format");
});
it("should create a valid UniqueID from an existing UUID", () => {
const validUUID = "550e8400-e29b-41d4-a716-446655440000";
const result = UniqueID.create(validUUID);
expect(result.isOk()).toBe(true);
expect(result.data.getValue()).toBe(validUUID);
});
it("should correctly convert UniqueID to string", () => {
const validUUID = "550e8400-e29b-41d4-a716-446655440000";
const result = UniqueID.create(validUUID);
expect(result.isOk()).toBe(true);
expect(result.data.toString()).toBe(validUUID);
});
});

View File

@ -0,0 +1 @@
export * from "./sequelize";

View File

@ -0,0 +1 @@
export * from "./sequelize-repository";

View File

@ -0,0 +1,68 @@
import { sequelize } from "@config/database";
import { FindOptions, Model, ModelDefined, Transaction } from "sequelize";
export abstract class SequelizeRepository<T extends Model> {
protected readonly model: ModelDefined<T>;
protected constructor(model: ModelDefined<T>) {
this.model = model;
}
async findById(id: string, transaction?: Transaction): Promise<T | null> {
return await this.model.findByPk(id, { transaction });
}
async findOneByField(field: string, value: any, transaction?: Transaction): Promise<T | null> {
return await this.model.findOne({ where: { [field]: value }, transaction });
}
async findAll(filter?: FindOptions, transaction?: Transaction): Promise<T[]> {
return await this.model.findAll({ ...filter, transaction });
}
async create(data: Partial<T>, transaction?: Transaction): Promise<T> {
return await this.model.create(data as any, { transaction });
}
async update(id: string, data: Partial<T>, transaction?: Transaction): Promise<[number, T[]]> {
return await this.model.update(data as any, { where: { id }, returning: true, transaction });
}
async delete(id: string, transaction?: Transaction): Promise<boolean> {
const deleted = await this.model.destroy({ where: { id }, transaction });
return deleted > 0;
}
/**
* 🔹 `exists`
* Verifica si un registro existe en la base de datos basado en un campo y valor.
*/
async exists(field: string, value: any, transaction?: Transaction): Promise<boolean> {
const count = await this.model.count({ where: { [field]: value }, transaction });
return count > 0;
}
/**
* 🔹 `count`
* Cuenta el número de registros que cumplen con una condición.
*/
async count(filter?: FindOptions, transaction?: Transaction): Promise<number> {
return await this.model.count({ ...filter, transaction });
}
/**
* 🔹 `executeTransaction`
* Ejecuta una función dentro de una transacción de Sequelize.
*/
async executeTransaction<R>(operation: (transaction: Transaction) => Promise<R>): Promise<R> {
const transaction = await sequelize.transaction();
try {
const result = await operation(transaction);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}

View File

@ -1,5 +1,5 @@
import { createApp } from "./config/app"
import { connectToDatabase } from "./config/database";
import { createApp } from "./infrastructure/app";
const PORT = process.env.PORT || 3000;

View File

@ -1,7 +1,8 @@
import express, { Application } from "express";
import responseTime from "response-time";
import helmet from "helmet";
import dotenv from "dotenv";
import express, { Application } from "express";
import helmet from "helmet";
import responseTime from "response-time";
import { authRoutes } from "./express";
dotenv.config();
@ -22,10 +23,8 @@ export function createApp(): Application {
app.set("port", process.env.PORT ?? 3002);
// Rutas (placeholder para bounded contexts)
app.get("/", (req, res) => {
res.json({ message: "¡Servidor funcionando!" });
});
// Registrar rutas del módulo de autenticación
app.use("/api/auth", authRoutes);
return app;
}

View File

@ -0,0 +1,13 @@
import { Router } from "express";
import * as authController from "../../application/auth/auth.controller";
export const authRoutes = () => {
const router = Router();
router.post("/register", authController.register);
router.post("/login", authController.login);
router.post("/select-company", authController.selectCompany);
router.post("/logout", authController.logout);
return router;
};

View File

@ -0,0 +1 @@
export * from "./auth.routes";

View File

@ -0,0 +1 @@
export * from "./app";

View File

@ -8,8 +8,9 @@
"allowSyntheticDefaultImports": true,
"baseUrl": "src",
"paths": {
"@shared/*": ["shared/*"],
"@common/*": ["common/*"]
"@shared/*": ["../../packages/shared/*"],
"@common/*": ["common/*"],
"@config/*": ["config/*"]
}
},