feat(auth): setup base auth domain

This commit is contained in:
2026-01-24 03:44:24 +08:00
parent 037e36f4f4
commit c8dc3b19a5
68 changed files with 2089 additions and 928 deletions

View File

@@ -1,31 +1,4 @@
# Express Starter Template
A robust **Modular Monolith** template for building scalable Node.js applications using **TypeScript**, **Express**, and **Clean Architecture** principles.
## 🚀 Features
- **Modular Architecture**: Vertical slice architecture ensuring separation of concerns and scalability.
- **Clean Architecture**: Domain-centric design with clear boundaries between Domain, Use Cases, and Infrastructure.
- **Type Safety**: Built with **TypeScript** in `nodenext` mode for modern ESM support.
- **Dependency Injection**: Powered by **InversifyJS** for loose coupling and testability.
- **Database**: **PostgreSQL** integration using **Prisma ORM** for type-safe database access and schema management.
- **Validation**: Runtime request validation using **Zod**.
- **Linting & Formatting**: Fast and efficient tooling with **Biome**.
## 🛠️ Tech Stack
- **Runtime**: Node.js (>= 22.18.0)
- **Framework**: Express.js
- **Language**: TypeScript
- **DI Container**: InversifyJS
- **Database**: PostgreSQL + Prisma ORM
- **Validation**: Zod
- **Testing**: Vitest
- **Tooling**: Biome, tsx, Swagger
For the first version, I'm planning of just using Express.js and InversifyJS. In the future, I plan on using the [InversifyJS framework with the Express v5 adapter](https://inversify.io/framework/docs/introduction/getting-started/) as another branch.
The `inversify-express-utils` package is already deprecated so the focus should be on the new framework package instead.
# Cedar CMS (Backend Monolith)
## 🏁 Getting Started
@@ -40,7 +13,7 @@ The `inversify-express-utils` package is already deprecated so the focus should
1. Clone the repository:
```bash
git clone <repository-url>
cd express-starter
cd cedar-api
```
2. Install dependencies:
@@ -51,10 +24,10 @@ The `inversify-express-utils` package is already deprecated so the focus should
3. Set up environment variables:
Create a `.env` file in the root directory (refer to `.env.example` if available, otherwise configure your DB connection details).
4. Create the initial Prisma migration:
> Note: Run this command every time you make changes to the Prisma schema.
4. Setup the database with the codebase's schema:
> Note: Run `yarn prisma:migrate` every time you make changes to the Prisma schema.
```bash
yarn prisma:migrate
yarn prisma:push
```
5. Generate Prisma Client:
@@ -77,6 +50,9 @@ The `inversify-express-utils` package is already deprecated so the focus should
- `yarn format`: Format the codebase using Biome.
- `yarn test`: Run unit tests using Vitest.
- `yarn coverage`: Run tests with coverage reporting.
- `yarn prisma:push`: Pushes the state of the Prisma schema to the database without migrations.
- `yarn prisma:migrate`: Creates a new migration while reruning the existing migration history. (use for development only)
- `yarn prisma:generate`: Generates the Prisma Client with updated schema.
## 🧪 Testing

View File

@@ -1,5 +1,5 @@
{
"name": "express-starter",
"name": "cedar-api",
"version": "1.0.0",
"license": "MIT",
"private": true,
@@ -12,6 +12,7 @@
"dev": "tsx watch src/app.ts",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"test": "vitest",
"coverage": "vitest run --coverage"
},

View File

@@ -4,7 +4,7 @@ import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
schema: "prisma/schema",
migrations: {
path: "prisma/migrations",
},

21
prisma/schema/auth.prisma Normal file
View File

@@ -0,0 +1,21 @@
model AuthIdentity {
id String @id @default(uuid())
email String @unique
password String
isVerified Boolean @default(false)
createdAt DateTime @default(now())
organizationMemberships OrganizationUserMembership[]
verifications AuthVerification[]
sentInvitations OrganizationInvitation[]
}
model AuthVerification {
id String @id @default(uuid())
identityId String
magicToken String
createdAt DateTime @default(now())
acceptedAt DateTime?
isAccepted Boolean @default(false)
isRevoked Boolean @default(false)
identity AuthIdentity @relation(fields: [identityId], references: [id])
}

View File

@@ -0,0 +1,36 @@
model OrganizationInvitation {
id String @id @default(uuid())
senderId String
organizationId String
inviteToken String
emailRecipient String
createdAt DateTime @default(now())
acceptedAt DateTime?
isAccepted Boolean @default(false)
isRevoked Boolean @default(false)
organization Organization @relation(fields: [organizationId], references: [id])
inviteSender AuthIdentity @relation(fields: [senderId], references: [id])
}
model Organization {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
organizationUsers OrganizationUserMembership[]
invitations OrganizationInvitation[]
}
model OrganizationUserMembership {
id String @id @default(uuid())
organizationId String
identityId String
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id])
identity AuthIdentity @relation(fields: [identityId], references: [id])
}
enum OrganizationRole {
OWNER
ADMIN
MEMBER
}

View File

@@ -0,0 +1,9 @@
generator client {
provider = "prisma-client"
output = "../../src/generated/prisma"
previewFeatures = ["relationJoins"]
}
datasource db {
provider = "postgresql"
}

View File

@@ -0,0 +1,30 @@
import type { DataOnlyDto } from "@/shared/core/DataOnlyDto.js";
import type {
FilterCriteria,
PaginationOptions,
PaginationResult,
} from "@/shared/core/IBaseRepository.js";
import type { AuthIdentityEntity } from "../domain/auth-identity.entity.js";
import type { AuthVerificationEntity } from "../domain/auth-verifications.entity.js";
export type AuthIdentityDto = Omit<
DataOnlyDto<AuthIdentityEntity>,
"verifications" | "password"
>;
export type AuthVerificationDto = DataOnlyDto<AuthVerificationEntity>;
export interface IAuthQueryService {
findIdentities(
filters?: FilterCriteria<AuthIdentityDto>,
pagination?: PaginationOptions,
): Promise<PaginationResult<AuthIdentityDto>>;
findById(id: string): Promise<AuthIdentityDto>;
findByEmail(id: string): Promise<AuthIdentityDto>;
getVerificationsByIdentityId(
identityId: string,
): Promise<AuthVerificationDto[]>;
getIdentityIdFromVerificationId(
verificationId: string,
): Promise<string | null>;
getIdentityIdFromMagicToken(magicToken: string): Promise<string | null>;
}

View File

@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { UserEntity } from "@/modules/users/domain/users.entity.js";
import { UsersInMemoryRepository } from "@/modules/users/infrastructure/fakes/users.in-memory.repo.js";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import { UserSignupUseCase } from "./user-signup.js";
import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js";
import { IdentityAlreadyExists } from "../../domain/errors/IdentityAlreadyExists.js";
import { AuthIdentityInMemoryRepository } from "../../infrastructure/persistence/fakes/auth.in-memory.repo.js";
import { CreateIdentityUseCase } from "./create-identity.js";
describe("Auth - User signup", () => {
let usersRepo: UsersInMemoryRepository;
describe("Auth - Create identity", () => {
let identityRepo: AuthIdentityInMemoryRepository;
const MockCryptoService = vi.fn(
class implements ICryptoService {
randomId = vi.fn().mockReturnValue("1");
@@ -14,20 +15,20 @@ describe("Auth - User signup", () => {
},
);
const cryptoService: ICryptoService = new MockCryptoService();
let useCase: UserSignupUseCase;
let useCase: CreateIdentityUseCase;
beforeEach(() => {
usersRepo = new UsersInMemoryRepository();
useCase = new UserSignupUseCase(usersRepo, cryptoService);
identityRepo = new AuthIdentityInMemoryRepository();
useCase = new CreateIdentityUseCase(identityRepo, cryptoService);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should signup a user", async () => {
const saveSpy = vi.spyOn(usersRepo, "save");
const findOneSpy = vi.spyOn(usersRepo, "findOne");
test("should create an identity", async () => {
const saveSpy = vi.spyOn(identityRepo, "save");
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const result = await useCase.execute({
email: "test@example.com",
password: "password",
@@ -39,37 +40,36 @@ describe("Auth - User signup", () => {
expect(result).toBeUndefined();
expect(
(
await usersRepo.findAll({
await identityRepo.findAll({
email: "test@example.com",
})
).data,
).toHaveLength(1);
expect(
(
await usersRepo.findAll({
await identityRepo.findAll({
email: "test@example.com",
})
).data,
).toHaveLength(1);
});
test("should throw an error if the user already exists", async () => {
// setup
await usersRepo.save(
new UserEntity(
test("should throw an error if an identity with the same email exists", async () => {
await identityRepo.save(
new AuthIdentityEntity(
"1",
"test@example.com",
"hashed-password",
true,
new Date(),
[],
),
);
// act
await expect(
useCase.execute({
email: "test@example.com",
password: "password",
}),
).rejects.toThrow("User already exists");
).rejects.toThrow(IdentityAlreadyExists);
});
});

View File

@@ -0,0 +1,42 @@
import { inject, injectable } from "inversify";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import type { IUseCase } from "@/shared/core/IUseCase.js";
import { AuthDomain } from "../../domain/auth.symbols.js";
import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js";
import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js";
import { IdentityAlreadyExists } from "../../domain/errors/IdentityAlreadyExists.js";
export type CreateIdentityDTO = {
email: string;
password: string;
};
@injectable()
export class CreateIdentityUseCase implements IUseCase<CreateIdentityDTO> {
constructor(
@inject(AuthDomain.IAuthIdentityRepository)
private readonly authIdentityRepository: IAuthIdentityRepository,
@inject(SharedDomain.ICryptoService)
private readonly cryptoService: ICryptoService,
) {}
async execute(dto: CreateIdentityDTO): Promise<void> {
const user = await this.authIdentityRepository.findOne({
email: dto.email,
});
if (user) {
throw new IdentityAlreadyExists();
}
const hashedPassword = await this.cryptoService.hashPassword(dto.password);
const userEntity = new AuthIdentityEntity(
this.cryptoService.randomId(),
dto.email,
hashedPassword,
false,
new Date(),
[],
);
await this.authIdentityRepository.save(userEntity);
}
}

View File

@@ -1,13 +1,14 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { UserEntity } from "@/modules/users/domain/users.entity.js";
import { UsersInMemoryRepository } from "@/modules/users/infrastructure/fakes/users.in-memory.repo.js";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { LoginUserUseCase } from "./login-user.js";
import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js";
import { InvalidCredentials } from "../../domain/errors/InvalidCredentials.js";
import { AuthIdentityInMemoryRepository } from "../../infrastructure/persistence/fakes/auth.in-memory.repo.js";
import { CreateSessionUseCase } from "./create-session.js";
describe("Auth - Login user", () => {
let usersRepo: UsersInMemoryRepository;
describe("Auth - Create session", () => {
let identityRepo: AuthIdentityInMemoryRepository;
const MockCryptoService = vi.fn(
class implements ICryptoService {
randomId = vi.fn().mockReturnValue("2");
@@ -41,9 +42,16 @@ describe("Auth - Login user", () => {
const logger = new MockLogger();
beforeEach(() => {
usersRepo = new UsersInMemoryRepository();
usersRepo.save(
new UserEntity("1", "test@example.com", "password", true, new Date()),
identityRepo = new AuthIdentityInMemoryRepository();
identityRepo.save(
new AuthIdentityEntity(
"1",
"test@example.com",
"password",
true,
new Date(),
[],
),
);
});
@@ -51,11 +59,11 @@ describe("Auth - Login user", () => {
vi.resetAllMocks();
});
test("should login a user", async () => {
const findOneSpy = vi.spyOn(usersRepo, "findOne");
test("should create a session", async () => {
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
const useCase = new LoginUserUseCase(
usersRepo,
const useCase = new CreateSessionUseCase(
identityRepo,
cryptoService,
tokenService,
logger,
@@ -72,45 +80,41 @@ describe("Auth - Login user", () => {
});
});
test("should not login a user if the user is not found", async () => {
const findOneSpy = vi.spyOn(usersRepo, "findOne");
test("should create a session if the identity is not found", async () => {
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
const useCase = new LoginUserUseCase(
usersRepo,
const useCase = new CreateSessionUseCase(
identityRepo,
cryptoService,
tokenService,
logger,
);
const result = await useCase.execute({
email: "test2@example.com",
password: "password",
});
await expect(
useCase.execute({
email: "test2@example.com",
password: "password",
}),
).rejects.toThrow(InvalidCredentials);
expect(findOneSpy).toHaveBeenCalledTimes(1);
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
expect(result).toEqual({
token: null,
refreshToken: null,
});
});
test("should not login a user if the password is invalid", async () => {
const findOneSpy = vi.spyOn(usersRepo, "findOne");
test("should create a session if the password is invalid", async () => {
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
const useCase = new LoginUserUseCase(
usersRepo,
const useCase = new CreateSessionUseCase(
identityRepo,
cryptoService,
tokenService,
logger,
);
const result = await useCase.execute({
email: "test@example.com",
password: "password2",
});
await expect(
useCase.execute({
email: "test@example.com",
password: "password2",
}),
).rejects.toThrow(InvalidCredentials);
expect(findOneSpy).toHaveBeenCalledTimes(1);
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
expect(result).toEqual({
token: null,
refreshToken: null,
});
});
});

View File

@@ -1,6 +1,4 @@
import { inject, injectable, optional } from "inversify";
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import type {
ILogger,
@@ -9,23 +7,26 @@ import type {
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import type { IUseCase } from "@/shared/core/IUseCase.js";
import { AuthDomain } from "../../domain/auth.symbols.js";
import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js";
import { InvalidCredentials } from "../../domain/errors/InvalidCredentials.js";
export type LoginUserDTO = {
export type CreateSessionDTO = {
email: string;
password: string;
};
export type LoginUserResult = {
export type CreateSessionResult = {
token: string | null;
refreshToken: string | null;
};
@injectable()
export class LoginUserUseCase
implements IUseCase<LoginUserDTO, LoginUserResult>
export class CreateSessionUseCase
implements IUseCase<CreateSessionDTO, CreateSessionResult>
{
constructor(
@inject(UsersDomain.IUserRepository)
private readonly userRepository: IUsersRepository,
@inject(AuthDomain.IAuthIdentityRepository)
private readonly authIdentityRepository: IAuthIdentityRepository,
@inject(SharedDomain.ICryptoService)
private readonly cryptoService: ICryptoService,
@inject(SharedDomain.ITokenService)
@@ -37,15 +38,17 @@ export class LoginUserUseCase
private readonly requestContext?: IRequestContext,
) {}
async execute(dto: LoginUserDTO): Promise<LoginUserResult> {
const user = await this.userRepository.findOne({ email: dto.email });
async execute(dto: CreateSessionDTO): Promise<CreateSessionResult> {
const user = await this.authIdentityRepository.findOne({
email: dto.email,
});
if (!user) {
this.logger.error({
message: "Invalid credentials",
module: "LoginUserUseCase",
module: "CreateSessionUseCase",
context: this.requestContext,
});
return { token: null, refreshToken: null };
throw new InvalidCredentials();
}
const isPasswordValid = await this.cryptoService.comparePassword(
dto.password,
@@ -54,16 +57,16 @@ export class LoginUserUseCase
if (!isPasswordValid) {
this.logger.error({
message: "Invalid credentials",
module: "LoginUserUseCase",
module: "CreateSessionUseCase",
context: this.requestContext,
});
return { token: null, refreshToken: null };
throw new InvalidCredentials();
}
const token = this.tokenService.generateToken(user);
const refreshToken = this.tokenService.generateRefreshToken(user);
this.logger.info({
message: "User logged in",
module: "LoginUserUseCase",
message: "Logged in.",
module: "CreateSessionUseCase",
context: this.requestContext,
});
return { token, refreshToken };

View File

@@ -1,28 +1,29 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { UserEntity } from "@/modules/users/domain/users.entity.js";
import { UsersInMemoryRepository } from "@/modules/users/infrastructure/fakes/users.in-memory.repo.js";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js";
import { InvalidSession } from "../../domain/errors/InvalidSession.js";
import { AuthIdentityInMemoryRepository } from "../../infrastructure/persistence/fakes/auth.in-memory.repo.js";
import { RefreshSessionUseCase } from "./refresh-session.js";
describe("Auth - Refresh session", () => {
let usersRepo: UsersInMemoryRepository;
let identityRepo: AuthIdentityInMemoryRepository;
const MockTokenService = vi.fn(
class implements ITokenService {
generateToken = vi.fn().mockReturnValue("token");
generateRefreshToken = vi.fn().mockReturnValue("refresh-token");
getSession = vi.fn().mockReturnValue({
userId: "1",
identityId: "1",
email: "test@example.com",
isVerified: true,
loginDate: new Date(),
});
validateRefreshToken = vi.fn((refreshToken) => {
if (refreshToken === "refresh-token") {
return { userId: "1" };
return { identityId: "1" };
}
if (refreshToken === "non-existant-user") {
return { userId: "2" };
return { identityId: "2" };
}
return null;
});
@@ -40,9 +41,16 @@ describe("Auth - Refresh session", () => {
const logger = new MockLogger();
beforeEach(() => {
usersRepo = new UsersInMemoryRepository();
usersRepo.save(
new UserEntity("1", "test@example.com", "password", true, new Date()),
identityRepo = new AuthIdentityInMemoryRepository();
identityRepo.save(
new AuthIdentityEntity(
"1",
"test@example.com",
"password",
true,
new Date(),
[],
),
);
});
@@ -51,9 +59,13 @@ describe("Auth - Refresh session", () => {
});
test("should refresh a session", async () => {
const findOneSpy = vi.spyOn(usersRepo, "findOne");
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
const useCase = new RefreshSessionUseCase(
identityRepo,
tokenService,
logger,
);
const result = await useCase.execute({
refreshToken: "refresh-token",
});
@@ -66,32 +78,36 @@ describe("Auth - Refresh session", () => {
});
test("should not refresh when refresh token is invalid", async () => {
const findOneSpy = vi.spyOn(usersRepo, "findOne");
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
const result = await useCase.execute({
refreshToken: "invalid-refresh-token",
});
const useCase = new RefreshSessionUseCase(
identityRepo,
tokenService,
logger,
);
await expect(
useCase.execute({
refreshToken: "invalid-refresh-token",
}),
).rejects.toThrow(InvalidSession);
expect(findOneSpy).toHaveBeenCalledTimes(0);
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
expect(result).toEqual({
token: null,
refreshToken: null,
});
});
test("should not refresh when user id does not exist", async () => {
const findOneSpy = vi.spyOn(usersRepo, "findOne");
const findOneSpy = vi.spyOn(identityRepo, "findOne");
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
const result = await useCase.execute({
refreshToken: "non-existant-user",
});
const useCase = new RefreshSessionUseCase(
identityRepo,
tokenService,
logger,
);
await expect(
useCase.execute({
refreshToken: "non-existant-user",
}),
).rejects.toThrow(InvalidSession);
expect(findOneSpy).toHaveBeenCalledTimes(1);
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
expect(result).toEqual({
token: null,
refreshToken: null,
});
});
});

View File

@@ -1,6 +1,4 @@
import { inject, injectable, optional } from "inversify";
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
import type {
ILogger,
IRequestContext,
@@ -8,6 +6,9 @@ import type {
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import type { IUseCase } from "@/shared/core/IUseCase.js";
import { AuthDomain } from "../../domain/auth.symbols.js";
import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js";
import { InvalidSession } from "../../domain/errors/InvalidSession.js";
export type RefreshSessionDTO = {
refreshToken: string;
@@ -22,8 +23,8 @@ export class RefreshSessionUseCase
implements IUseCase<RefreshSessionDTO, RefreshSessionResult>
{
constructor(
@inject(UsersDomain.IUserRepository)
private readonly userRepository: IUsersRepository,
@inject(AuthDomain.IAuthIdentityRepository)
private readonly userRepository: IAuthIdentityRepository,
@inject(SharedDomain.ITokenService)
private readonly tokenService: ITokenService,
@inject(SharedDomain.ILogger)
@@ -37,22 +38,24 @@ export class RefreshSessionUseCase
const refreshData = this.tokenService.validateRefreshToken(
dto.refreshToken,
);
if (!refreshData?.userId) {
if (!refreshData?.identityId) {
this.logger.error({
message: "Invalid refresh token",
module: "RefreshSessionUseCase",
context: this.requestContext,
});
return { token: null, refreshToken: null };
throw new InvalidSession();
}
const user = await this.userRepository.findOne({ id: refreshData.userId });
const user = await this.userRepository.findOne({
id: refreshData.identityId,
});
if (!user) {
this.logger.error({
message: "Invalid refresh token",
module: "RefreshSessionUseCase",
context: this.requestContext,
});
return { token: null, refreshToken: null };
throw new InvalidSession();
}
const token = this.tokenService.generateToken(user);
const refreshToken = this.tokenService.generateRefreshToken(user);

View File

@@ -0,0 +1,203 @@
import { describe, expect, test, vi } from "vitest";
import { AuthIdentityEntity } from "./auth-identity.entity.js";
import { AuthVerificationEntity } from "./auth-verifications.entity.js";
import { IdentityAlreadyVerified } from "./errors/IdentityAlreadyVerified.js";
import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
import { InvalidMagicToken } from "./errors/InvalidMagicToken.js";
import { InvalidPassword } from "./errors/InvalidPassword.js";
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
describe("Auth - AuthIdentityEntity", () => {
test("should create a user entity", () => {
const identity = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
expect(identity).toBeDefined();
expect(identity.id).toBe("1");
expect(identity.email).toBe("test@example.com");
expect(identity.password).toBe("password");
expect(identity.isVerified).toBe(false);
expect(identity.createdAt).toBeInstanceOf(Date);
});
test("should throw an error if the email is invalid", () => {
expect(() => {
new AuthIdentityEntity("1", "test", "password", false, new Date(), []);
}).toThrowError(InvalidEmailFormat);
});
test("should throw an error if the password is invalid", () => {
expect(() => {
new AuthIdentityEntity(
"1",
"test@example.com",
"",
false,
new Date(),
[],
);
}).toThrowError(InvalidPassword);
});
test("should get account age in seconds", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 0));
const identity = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
// advance time by 5 seconds
vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 5));
expect(identity.getAccountAge()).toBe(5);
vi.useRealTimers();
});
test("should change email successfully", () => {
const user = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
user.changeEmail("test2@example.com");
expect(user.email).toBe("test2@example.com");
});
test("should throw an error if the new email is invalid", () => {
const identity = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
expect(() => {
identity.changeEmail("test");
}).toThrowError(InvalidEmailFormat);
});
test("should change password successfully", () => {
const identity = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
identity.changePassword("password2");
expect(identity.password).toBe("password2");
});
test("should verify via magic token successfully", () => {
const verification = new AuthVerificationEntity(
"verification-1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
const identity = new AuthIdentityEntity(
"identity-1",
"user@example.com",
"password",
false,
new Date(),
[verification],
);
identity.verifyViaMagicToken("token-1");
expect(identity.isVerified).toBeTruthy();
});
test("should throw an error if the new password is the same as the old password", () => {
const identity = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
expect(() => {
identity.changePassword("password");
}).toThrowError(NewPasswordMustBeDifferent);
});
test("should throw an error if the new password is invalid", () => {
const identity = new AuthIdentityEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
[],
);
expect(() => {
identity.changePassword("");
}).toThrowError(InvalidPassword);
});
test("should throw an error if the magic token is invalid", () => {
const verification = new AuthVerificationEntity(
"verification-1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
const identity = new AuthIdentityEntity(
"identity-1",
"user@example.com",
"password",
false,
new Date(),
[verification],
);
expect(() => {
identity.verifyViaMagicToken("invalid-token");
}).toThrow(InvalidMagicToken);
});
test("should throw an error if the user is already verified", () => {
const verification = new AuthVerificationEntity(
"verification-1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
const identity = new AuthIdentityEntity(
"identity-1",
"user@example.com",
"password",
true,
new Date(),
[verification],
);
expect(() => {
identity.verifyViaMagicToken("token-1");
}).toThrow(IdentityAlreadyVerified);
});
});

View File

@@ -1,14 +1,18 @@
import type { AuthVerificationEntity } from "./auth-verifications.entity.js";
import { IdentityAlreadyVerified } from "./errors/IdentityAlreadyVerified.js";
import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
import { InvalidMagicToken } from "./errors/InvalidMagicToken.js";
import { InvalidPassword } from "./errors/InvalidPassword.js";
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
export class UserEntity {
export class AuthIdentityEntity {
constructor(
public id: string,
public email: string,
public password: string,
public isVerified: boolean,
public createdAt: Date,
public verifications: AuthVerificationEntity[],
) {
if (!email.includes("@")) {
throw new InvalidEmailFormat();
@@ -18,14 +22,28 @@ export class UserEntity {
}
}
/**
* Returns the age of the account in seconds
* @returns account age in seconds
*/
getAccountAge() {
return (Date.now() - this.createdAt.getTime()) / 1000;
}
verifyViaMagicToken(magicToken: string) {
if (this.isVerified) {
throw new IdentityAlreadyVerified();
}
let verification: AuthVerificationEntity | null = null;
for (const entity of this.verifications) {
if (entity.magicToken === magicToken) {
verification = entity;
break;
}
}
if (verification === null) {
throw new InvalidMagicToken();
}
verification.accept();
this.isVerified = true;
}
changeEmail(newEmail: string) {
if (!newEmail.includes("@")) {
throw new InvalidEmailFormat();
@@ -42,8 +60,4 @@ export class UserEntity {
}
this.password = newHashedPassword;
}
setVerifiedStatus(verifiedStatus: boolean) {
this.isVerified = verifiedStatus;
}
}

View File

@@ -0,0 +1,5 @@
import type { IBaseRepository } from "@/shared/core/IBaseRepository.js";
import type { AuthIdentityEntity } from "./auth-identity.entity.js";
export interface IAuthIdentityRepository
extends IBaseRepository<AuthIdentityEntity> {}

View File

@@ -0,0 +1,145 @@
import { describe, expect, test } from "vitest";
import { AuthVerificationEntity } from "./auth-verifications.entity.js";
import { VerificationAlreadyAccepted } from "./errors/VerificationAlreadyAccepted.js";
import { VerificationAlreadyRevoked } from "./errors/VerificationAlreadyRevoked.js";
describe("Users - UserVerificationEntity", () => {
test("should create a user verification", () => {
const authVerification = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
expect(authVerification).toBeDefined();
expect(authVerification.id).toBe("1");
expect(authVerification.identityId).toBe("identity-1");
expect(authVerification.magicToken).toBe("token-1");
expect(authVerification.createdAt).toBeInstanceOf(Date);
expect(authVerification.acceptedAt).toBeNull();
expect(authVerification.isAccepted).toBeFalsy();
expect(authVerification.isRevoked).toBeFalsy();
expect(authVerification.isVerified()).toBeFalsy();
});
test("should accept user verification", () => {
const authVerification = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
authVerification.accept();
expect(authVerification).toBeDefined();
expect(authVerification.id).toBe("1");
expect(authVerification.identityId).toBe("identity-1");
expect(authVerification.magicToken).toBe("token-1");
expect(authVerification.createdAt).toBeInstanceOf(Date);
expect(authVerification.acceptedAt).toBeInstanceOf(Date);
expect(authVerification.isAccepted).toBeTruthy();
expect(authVerification.isRevoked).toBeFalsy();
expect(authVerification.isVerified()).toBeTruthy();
});
test("should revoke user verification (not yet accepted)", () => {
const userVerification = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
userVerification.revoke();
expect(userVerification).toBeDefined();
expect(userVerification.id).toBe("1");
expect(userVerification.identityId).toBe("identity-1");
expect(userVerification.magicToken).toBe("token-1");
expect(userVerification.createdAt).toBeInstanceOf(Date);
expect(userVerification.acceptedAt).toBeNull();
expect(userVerification.isAccepted).toBeFalsy();
expect(userVerification.isRevoked).toBeTruthy();
expect(userVerification.isVerified()).toBeFalsy();
});
test("should revoke user verification (already accepted)", () => {
const authVerification = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
null,
false,
false,
);
authVerification.accept();
authVerification.revoke();
expect(authVerification).toBeDefined();
expect(authVerification.id).toBe("1");
expect(authVerification.identityId).toBe("identity-1");
expect(authVerification.magicToken).toBe("token-1");
expect(authVerification.createdAt).toBeInstanceOf(Date);
expect(authVerification.acceptedAt).toBeInstanceOf(Date);
expect(authVerification.isAccepted).toBeTruthy();
expect(authVerification.isRevoked).toBeTruthy();
expect(authVerification.isVerified()).toBeFalsy();
});
test("should throw an error when trying to accept while already accepted", () => {
const authVerification = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
new Date(),
true,
false,
);
expect(() => {
authVerification.accept();
}).toThrowError(VerificationAlreadyAccepted);
});
test("should throw an error when trying to revoke while already revoked", () => {
const notAccepted = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
null,
false,
true,
);
const accepted = new AuthVerificationEntity(
"1",
"identity-1",
"token-1",
new Date(),
new Date(),
true,
true,
);
expect(() => {
notAccepted.revoke();
}).toThrowError(VerificationAlreadyRevoked);
expect(() => {
accepted.revoke();
}).toThrowError(VerificationAlreadyRevoked);
});
});

View File

@@ -0,0 +1,33 @@
import { VerificationAlreadyAccepted } from "./errors/VerificationAlreadyAccepted.js";
import { VerificationAlreadyRevoked } from "./errors/VerificationAlreadyRevoked.js";
export class AuthVerificationEntity {
constructor(
public id: string,
public identityId: string,
public magicToken: string,
public createdAt: Date,
public acceptedAt: Date | null,
public isAccepted: boolean,
public isRevoked: boolean,
) {}
isVerified(): boolean {
return this.isAccepted && !this.isRevoked;
}
accept(): void {
if (this.isAccepted) {
throw new VerificationAlreadyAccepted();
}
this.isAccepted = true;
this.acceptedAt = new Date();
}
revoke(): void {
if (this.isRevoked) {
throw new VerificationAlreadyRevoked();
}
this.isRevoked = true;
}
}

View File

@@ -0,0 +1,5 @@
export const AuthDomain = {
// IAuthIdentityGateway: Symbol.for("IAuthIdentityGateway"),
IAuthIdentityRepository: Symbol.for("IAuthIdentityRepository"),
IAuthIdentityQueryService: Symbol.for("IAuthIdentityQueryService"),
};

View File

@@ -0,0 +1,5 @@
export class IdentityAlreadyExists extends Error {
constructor() {
super("Identity already exists");
}
}

View File

@@ -0,0 +1,5 @@
export class IdentityAlreadyVerified extends Error {
constructor() {
super("Identity already verified");
}
}

View File

@@ -0,0 +1,5 @@
export class IdentityNotFound extends Error {
constructor() {
super("Identity not found");
}
}

View File

@@ -0,0 +1,5 @@
export class InvalidCredentials extends Error {
constructor() {
super("Invalid credentails.");
}
}

View File

@@ -0,0 +1,5 @@
export class InvalidMagicToken extends Error {
constructor() {
super("Invalid magic token");
}
}

View File

@@ -0,0 +1,5 @@
export class InvalidSession extends Error {
constructor() {
super("Invalid session.");
}
}

View File

@@ -0,0 +1,5 @@
export class VerificationAlreadyAccepted extends Error {
constructor() {
super("Verification was already accepted.");
}
}

View File

@@ -0,0 +1,5 @@
export class VerificationAlreadyRevoked extends Error {
constructor() {
super("Verification was already revoked.");
}
}

View File

@@ -1,10 +1,16 @@
import { ContainerModule } from "inversify";
import { LoginUserUseCase } from "../../use-cases/login-user.js";
import { RefreshSessionUseCase } from "../../use-cases/refresh-session.js";
import { UserSignupUseCase } from "../../use-cases/user-signup.js";
import { CreateIdentityUseCase } from "../../application/use-cases/create-identity.js";
import { CreateSessionUseCase } from "../../application/use-cases/create-session.js";
import { RefreshSessionUseCase } from "../../application/use-cases/refresh-session.js";
import { AuthDomain } from "../../domain/auth.symbols.js";
import { AuthIdentityPrismaRepository } from "../persistence/auth.prisma.repo.js";
import { AuthPrismaQueryService } from "../persistence/auth.prisma.service.js";
export const AuthDIModule = new ContainerModule(({ bind }) => {
bind(LoginUserUseCase).toSelf().inTransientScope();
bind(AuthDomain.IAuthIdentityRepository).to(AuthIdentityPrismaRepository);
bind(AuthDomain.IAuthIdentityQueryService).to(AuthPrismaQueryService);
bind(CreateSessionUseCase).toSelf().inTransientScope();
bind(RefreshSessionUseCase).toSelf().inTransientScope();
bind(UserSignupUseCase).toSelf().inTransientScope();
bind(CreateIdentityUseCase).toSelf().inTransientScope();
});

View File

@@ -5,9 +5,9 @@ import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { appContainer } from "@/shared/infrastructure/di/Container.js";
import { requireAuth } from "@/shared/infrastructure/http/middlewares/requireAuth.js";
import { respondWithGenericError } from "@/shared/infrastructure/http/responses/respondWithGenericError.js";
import { LoginUserUseCase } from "../../use-cases/login-user.js";
import { RefreshSessionUseCase } from "../../use-cases/refresh-session.js";
import { UserSignupUseCase } from "../../use-cases/user-signup.js";
import { CreateIdentityUseCase } from "../../application/use-cases/create-identity.js";
import { CreateSessionUseCase } from "../../application/use-cases/create-session.js";
import { RefreshSessionUseCase } from "../../application/use-cases/refresh-session.js";
const router = Router();
@@ -35,7 +35,7 @@ const LoginRequestSchema = z.object({
* post:
* tags:
* - Auth
* summary: Login a user
* summary: Login via an identity and create a session
* requestBody:
* required: true
* content:
@@ -53,7 +53,7 @@ router.post("/login", async (req, res) => {
SharedDomain.IConfigService,
);
const { email, password } = LoginRequestSchema.parse(req.body);
const useCase = appContainer.get(LoginUserUseCase);
const useCase = appContainer.get(CreateSessionUseCase);
const { token, refreshToken } = await useCase.execute({ email, password });
if (token && refreshToken) {
@@ -116,7 +116,7 @@ const RegisterRequestSchema = z.object({
*/
router.post("/register", async (req, res) => {
const { email, password } = RegisterRequestSchema.parse(req.body);
const useCase = appContainer.get(UserSignupUseCase);
const useCase = appContainer.get(CreateIdentityUseCase);
await useCase.execute({ email, password });
res.status(200).send();
});

View File

@@ -0,0 +1,69 @@
import type {
AuthIdentity,
AuthVerification,
PrismaClient,
} from "@/generated/prisma/client.js";
import type { IAsyncMapper, IMapper } from "@/shared/core/IMapper.js";
import { AuthIdentityEntity } from "../../domain/auth-identity.entity.js";
import { AuthVerificationEntity } from "../../domain/auth-verifications.entity.js";
export class AuthIdentityPrismaMapper
implements IAsyncMapper<AuthIdentityEntity, AuthIdentity>
{
private authVerificationMapper: AuthVerificationPrismaMapper;
constructor(private prisma: PrismaClient) {
this.authVerificationMapper = new AuthVerificationPrismaMapper();
}
async toDomain(model: AuthIdentity): Promise<AuthIdentityEntity> {
const verificationModels = await this.prisma.authVerification.findMany({
where: {
id: model.id,
},
});
return new AuthIdentityEntity(
model.id,
model.email,
model.password,
model.isVerified,
model.createdAt,
verificationModels.map(this.authVerificationMapper.toDomain),
);
}
async toModel(entity: AuthIdentityEntity): Promise<AuthIdentity> {
return {
id: entity.id,
email: entity.email,
password: entity.password,
isVerified: entity.isVerified,
createdAt: entity.createdAt,
};
}
}
export class AuthVerificationPrismaMapper
implements IMapper<AuthVerificationEntity, AuthVerification>
{
toDomain(model: AuthVerification): AuthVerificationEntity {
return new AuthVerificationEntity(
model.id,
model.identityId,
model.magicToken,
model.createdAt,
model.acceptedAt,
model.isAccepted,
model.isRevoked,
);
}
toModel(entity: AuthVerificationEntity): AuthVerification {
return {
id: entity.id,
identityId: entity.identityId,
magicToken: entity.magicToken,
createdAt: entity.createdAt,
acceptedAt: entity.acceptedAt,
isAccepted: entity.isAccepted,
isRevoked: entity.isRevoked,
};
}
}

View File

@@ -0,0 +1,190 @@
import { inject, injectable } from "inversify";
import type { AuthIdentity } from "@/generated/prisma/client.js";
import type {
AuthIdentityModel,
AuthIdentityWhereInput,
DateTimeFilter,
} from "@/generated/prisma/models.js";
import type {
FilterCriteria,
PaginationOptions,
PaginationResult,
} from "@/shared/core/IBaseRepository.js";
import {
type PrismaClient,
PrismaClientWrapper,
} from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js";
import type { AuthIdentityEntity } from "../../domain/auth-identity.entity.js";
import type { IAuthIdentityRepository } from "../../domain/auth-identity.repo.js";
import {
AuthIdentityPrismaMapper,
AuthVerificationPrismaMapper,
} from "./auth.prisma.mappers.js";
@injectable()
export class AuthIdentityPrismaRepository implements IAuthIdentityRepository {
private readonly prisma: PrismaClient;
private identityMapper: AuthIdentityPrismaMapper;
private verificationMapper: AuthVerificationPrismaMapper;
constructor(
@inject(PrismaClientWrapper)
private readonly prismaClientWrapper: PrismaClientWrapper,
) {
this.prisma = this.prismaClientWrapper.getClient();
this.identityMapper = new AuthIdentityPrismaMapper(this.prisma);
this.verificationMapper = new AuthVerificationPrismaMapper();
}
private toModelFilter(
criteria: FilterCriteria<AuthIdentityEntity>,
): FilterCriteria<AuthIdentity> {
const result: FilterCriteria<AuthIdentity> = {};
if (criteria.id !== undefined) result.id = criteria.id;
if (criteria.email !== undefined) result.email = criteria.email;
if (criteria.password !== undefined) result.password = criteria.password;
if (criteria.createdAt !== undefined) result.createdAt = criteria.createdAt;
return result;
}
private getWhereInput(
modelFilter: FilterCriteria<AuthIdentityModel>,
): AuthIdentityWhereInput {
const where: AuthIdentityWhereInput = {};
if (modelFilter.id !== undefined) {
where.id = modelFilter.id;
}
if (modelFilter.email !== undefined) {
where.email = modelFilter.email;
}
if (modelFilter.isVerified !== undefined) {
where.isVerified = modelFilter.isVerified;
}
if (modelFilter.createdAt !== undefined) {
if (modelFilter.createdAt instanceof Date) {
where.createdAt = modelFilter.createdAt;
} else {
const range: DateTimeFilter<"User"> = {};
if (modelFilter.createdAt.from !== undefined) {
range.gte = modelFilter.createdAt.from;
}
if (modelFilter.createdAt.to !== undefined) {
range.lte = modelFilter.createdAt.to;
}
}
}
return where;
}
async findOne(
criteria: FilterCriteria<AuthIdentityEntity>,
): Promise<AuthIdentityEntity | null> {
const modelFilter = this.toModelFilter(criteria);
const where = this.getWhereInput(modelFilter);
const model = await this.prisma.authIdentity.findFirst({
where,
include: {
verifications: true,
},
});
return model ? await this.identityMapper.toDomain(model) : null;
}
async findById(id: string): Promise<AuthIdentityEntity | null> {
const model = await this.prisma.authIdentity.findUnique({
where: { id },
include: { verifications: true },
});
return model ? await this.identityMapper.toDomain(model) : null;
}
async findAll(
criteria?: FilterCriteria<AuthIdentityEntity>,
paginationOptions?: PaginationOptions,
): Promise<PaginationResult<AuthIdentityEntity>> {
const modelFilter = criteria ? this.toModelFilter(criteria) : {};
const where = this.getWhereInput(modelFilter);
const models = paginationOptions
? await this.prisma.authIdentity.findMany({
where,
take: paginationOptions.limit,
skip: paginationOptions.offset,
include: {
verifications: true,
},
})
: await this.prisma.authIdentity.findMany({
where,
include: {
verifications: true,
},
});
const total = await this.prisma.authIdentity.count({ where });
return {
data: await Promise.all(models.map(this.identityMapper.toDomain)),
total,
};
}
async save(entity: AuthIdentityEntity): Promise<AuthIdentityEntity | null> {
const verificationSnapshot = await this.prisma.authVerification.findMany({
where: { identityId: entity.id },
});
const model = await this.prisma.authIdentity.upsert({
where: { id: entity.id },
create: await this.identityMapper.toModel(entity),
update: await this.identityMapper.toModel(entity),
include: {
verifications: true,
},
});
// remove verification objects that are no longer present in entity
const removedIds = verificationSnapshot
.map((snapshot) => snapshot.id)
.filter(
(id) =>
!entity.verifications
.map((verificaiton) => verificaiton.id)
.includes(id),
);
await this.prisma.authVerification.deleteMany({
where: {
id: {
in: removedIds,
},
},
});
// create verification objects that were previously not present in db snapshot
const addedEntities = entity.verifications.filter(
(verification) =>
!verificationSnapshot
.map((snapshot) => snapshot.id)
.includes(verification.id),
);
await this.prisma.authVerification.createMany({
data: addedEntities.map(this.verificationMapper.toModel),
});
// update models that exist in both db snapshot and entity
const possiblyUpdatedModels = verificationSnapshot.filter((snapshot) =>
entity.verifications
.map((verificaiton) => verificaiton.id)
.includes(snapshot.id),
);
await Promise.all(
possiblyUpdatedModels.map(async (model) => {
await this.prisma.authVerification.update({
where: {
id: model.id,
},
data: model,
});
}),
);
return model ? this.identityMapper.toDomain(model) : null;
}
generateId(): string {
return this.prismaClientWrapper.generateId();
}
}

View File

@@ -0,0 +1,183 @@
import { inject, injectable } from "inversify";
import type { PrismaClient } from "@/generated/prisma/client.js";
import type {
AuthIdentityWhereInput,
DateTimeFilter,
} from "@/generated/prisma/models.js";
import type {
FilterCriteria,
PaginationOptions,
PaginationResult,
} from "@/shared/core/IBaseRepository.js";
import { PrismaClientWrapper } from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js";
import type {
AuthIdentityDto,
AuthVerificationDto,
IAuthQueryService,
} from "../../application/query-service.js";
import { IdentityNotFound } from "../../domain/errors/IdentityNotFound.js";
@injectable()
export class AuthPrismaQueryService implements IAuthQueryService {
private readonly prisma: PrismaClient;
constructor(
@inject(PrismaClientWrapper)
prismaClientWrapper: PrismaClientWrapper,
) {
this.prisma = prismaClientWrapper.getClient();
}
private getWhereInput(
modelFilter: FilterCriteria<AuthIdentityDto>,
): AuthIdentityWhereInput {
const where: AuthIdentityWhereInput = {};
if (modelFilter.id !== undefined) {
where.id = modelFilter.id;
}
if (modelFilter.email !== undefined) {
where.email = modelFilter.email;
}
if (modelFilter.isVerified !== undefined) {
where.isVerified = modelFilter.isVerified;
}
if (modelFilter.createdAt !== undefined) {
if (modelFilter.createdAt instanceof Date) {
where.createdAt = modelFilter.createdAt;
} else {
const range: DateTimeFilter<"User"> = {};
if (modelFilter.createdAt.from !== undefined) {
range.gte = modelFilter.createdAt.from;
}
if (modelFilter.createdAt.to !== undefined) {
range.lte = modelFilter.createdAt.to;
}
}
}
return where;
}
async findIdentities(
filters?: FilterCriteria<AuthIdentityDto>,
paginationOptions?: PaginationOptions,
): Promise<PaginationResult<AuthIdentityDto>> {
const where = filters ? this.getWhereInput(filters) : {};
const models = paginationOptions
? await this.prisma.authIdentity.findMany({
where,
skip: paginationOptions.offset,
take: paginationOptions.limit,
})
: await this.prisma.authIdentity.findMany({
where,
});
const total = await this.prisma.authIdentity.count({ where });
const data: AuthIdentityDto[] = models.map(
({ id, email, isVerified, createdAt }) => ({
id,
email,
isVerified,
createdAt,
}),
);
return {
data,
total,
};
}
async findById(id: string): Promise<AuthIdentityDto> {
const model = await this.prisma.authIdentity.findFirst({
where: {
id,
},
});
if (model === null) {
throw new IdentityNotFound();
}
return {
id: model.id,
email: model.email,
isVerified: model.isVerified,
createdAt: model.createdAt,
};
}
async findByEmail(email: string): Promise<AuthIdentityDto> {
const model = await this.prisma.authIdentity.findFirst({
where: {
email,
},
});
if (model === null) {
throw new IdentityNotFound();
}
return {
id: model.id,
email: model.email,
isVerified: model.isVerified,
createdAt: model.createdAt,
};
}
async getVerificationsByIdentityId(
identityId: string,
): Promise<AuthVerificationDto[]> {
const models = await this.prisma.authVerification.findMany({
where: {
identityId,
},
});
return models.map(
({
id,
identityId,
magicToken,
createdAt,
acceptedAt,
isAccepted,
isRevoked,
}) => ({
id,
identityId,
magicToken,
createdAt,
acceptedAt,
isAccepted,
isRevoked,
}),
);
}
async getIdentityIdFromVerificationId(
verificationId: string,
): Promise<string> {
const model = await this.prisma.authVerification.findFirst({
where: {
id: verificationId,
},
});
if (model === null) {
throw new IdentityNotFound();
}
return model.identityId;
}
async getIdentityIdFromMagicToken(magicToken: string): Promise<string> {
const model = await this.prisma.authVerification.findFirst({
where: {
magicToken,
},
});
if (model === null) {
throw new IdentityNotFound();
}
return model.identityId;
}
}

View File

@@ -0,0 +1,203 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, test } from "vitest";
import { AuthIdentityEntity } from "../../../domain/auth-identity.entity.js";
import type { AuthVerificationEntity } from "../../../domain/auth-verifications.entity.js";
import { IdentityNotFound } from "../../../domain/errors/IdentityNotFound.js";
import { AuthInMemoryQueryService } from "./auth.in-memory.query-service.js";
import { AuthIdentityInMemoryRepository } from "./auth.in-memory.repo.js";
describe("AuthInMemoryQueryService", () => {
let queryService: AuthInMemoryQueryService;
let repo: AuthIdentityInMemoryRepository;
beforeEach(() => {
repo = new AuthIdentityInMemoryRepository();
queryService = new AuthInMemoryQueryService(repo);
new AuthInMemoryQueryService();
});
const createIdentity = (
overrides: Partial<AuthIdentityEntity> = {},
): AuthIdentityEntity => {
return new AuthIdentityEntity(
overrides.id ?? randomUUID(),
overrides.email ?? "test@example.com",
overrides.password ?? "password",
overrides.isVerified ?? false,
overrides.createdAt ?? new Date(),
overrides.verifications ?? [],
);
};
const createVerification = (
overrides: Partial<AuthVerificationEntity> = {},
): AuthVerificationEntity => {
return {
id: overrides.id ?? randomUUID(),
identityId: overrides.identityId ?? randomUUID(),
magicToken: overrides.magicToken ?? randomUUID(),
createdAt: overrides.createdAt ?? new Date(),
acceptedAt: overrides.acceptedAt ?? null,
isAccepted: overrides.isAccepted ?? false,
isRevoked: overrides.isRevoked ?? false,
isVerified: () => false, // Mock implementation if needed
accept: () => {},
revoke: () => {},
} as AuthVerificationEntity;
};
test("findIdentities - should return all identities if no filters provided", async () => {
const identity1 = createIdentity({ email: "identity1@example.com" });
const identity2 = createIdentity({ email: "identity2@example.com" });
await repo.save(identity1);
await repo.save(identity2);
const result = await queryService.findIdentities();
expect(result.total).toBe(2);
expect(result.data).toHaveLength(2);
expect(result.data.at(0)?.id).toBe(identity1.id);
expect(result.data.at(1)?.id).toBe(identity2.id);
});
test("findIdentities - should filter identities by email", async () => {
const identity1 = createIdentity({ email: "identity1@example.com" });
const identity2 = createIdentity({ email: "identity2@example.com" });
await repo.save(identity1);
await repo.save(identity2);
const result = await queryService.findIdentities({
email: "identity1@example.com",
});
expect(result.total).toBe(1);
expect(result.data.at(0)?.id).toBe(identity1.id);
});
test("findOneById - should return identity if found", async () => {
const identity = createIdentity();
await repo.save(identity);
const result = await queryService.findById(identity.id);
expect(result.id).toBe(identity.id);
});
test("findOneById - should throw IdentityNotFound if not found", async () => {
await expect(queryService.findById("non-existent-id")).rejects.toThrow(
IdentityNotFound,
);
});
test("findByEmail - should return identity if found", async () => {
const identity = createIdentity();
await repo.save(identity);
const result = await queryService.findByEmail(identity.email);
expect(result.id).toBe(identity.id);
});
test("findByEmail - should throw IdentityNotFound if not found", async () => {
await expect(
queryService.findByEmail("non-existent-email@example.com"),
).rejects.toThrow(IdentityNotFound);
});
test("getVerificationsByIdentityId - should return verifications for identity", async () => {
const verification = createVerification();
const identity = createIdentity({ verifications: [verification] });
await repo.save(identity);
const result = await queryService.getVerificationsByIdentityId(identity.id);
expect(result).toHaveLength(1);
expect(result.at(0)?.id).toBe(verification.id);
});
test("getVerificationsByIdentityId - should return empty array if identity not found", async () => {
const result =
await queryService.getVerificationsByIdentityId("non-existent");
expect(result).toEqual([]);
});
test("getIdentityIdFromVerificationId - should return identityId if verification found", async () => {
const identity = createIdentity();
const verification = createVerification({
identityId: identity.id,
});
identity.verifications.push(verification);
await repo.save(identity);
const result = await queryService.getIdentityIdFromVerificationId(
verification.id,
);
expect(result).toBe(identity.id);
});
test("getIdentityIdFromVerificationId - should return null if verification not found", async () => {
await expect(
queryService.getIdentityIdFromVerificationId("non-existent"),
).resolves.toBeNull();
});
test("getIdentityIdFromVerificationId - should return null if verification id does not exist even if other verifications exist", async () => {
const identity = createIdentity();
const verification = createVerification({
identityId: identity.id,
});
identity.verifications.push(verification);
await repo.save(identity);
const result = await queryService.getIdentityIdFromVerificationId(
"non-existent-verification-id",
);
expect(result).toBeNull();
});
test("getIdentityIdFromVerificationId - should return null if verification exists but identityId does not exist", async () => {
const identity = createIdentity();
const verification = createVerification({
identityId: "non-existent-identity-id",
});
identity.verifications.push(verification);
await repo.save(identity);
const result = await queryService.getIdentityIdFromVerificationId(
verification.id,
);
expect(result).toBeNull();
});
test("getIdentityIdFromMagicToken - should return identityId if magic token found", async () => {
const identity = createIdentity();
const verification = createVerification({
identityId: identity.id,
magicToken: "magic-token",
});
identity.verifications.push(verification);
await repo.save(identity);
const result =
await queryService.getIdentityIdFromMagicToken("magic-token");
expect(result).toBe(identity.id);
});
test("getIdentityIdFromMagicToken - should return null if magic token not found", async () => {
const identity = createIdentity();
const verification = createVerification({
identityId: identity.id,
magicToken: "magic-token",
});
identity.verifications.push(verification);
await repo.save(identity);
await expect(
queryService.getIdentityIdFromMagicToken("non-existent"),
).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,127 @@
import type {
AuthIdentityDto,
AuthVerificationDto,
IAuthQueryService,
} from "@/modules/auth/application/query-service.js";
import { IdentityNotFound } from "@/modules/auth/domain/errors/IdentityNotFound.js";
import type {
FilterCriteria,
PaginationOptions,
PaginationResult,
} from "@/shared/core/IBaseRepository.js";
import { AuthIdentityInMemoryRepository } from "./auth.in-memory.repo.js";
export class AuthInMemoryQueryService implements IAuthQueryService {
private repo: AuthIdentityInMemoryRepository;
constructor(usersInMemoryRepository?: AuthIdentityInMemoryRepository) {
this.repo = usersInMemoryRepository ?? new AuthIdentityInMemoryRepository();
}
async findIdentities(
filters?: FilterCriteria<AuthIdentityDto>,
pagination?: PaginationOptions,
): Promise<PaginationResult<AuthIdentityDto>> {
const items = await this.repo.findAll(filters, pagination);
return {
data: items.data.map((user) => ({
id: user.id,
email: user.email,
isVerified: user.isVerified,
createdAt: user.createdAt,
})),
total: items.total,
};
}
async findById(id: string): Promise<AuthIdentityDto> {
const identity = await this.repo.findById(id);
if (!identity) {
throw new IdentityNotFound();
}
return {
id: identity.id,
email: identity.email,
isVerified: identity.isVerified,
createdAt: identity.createdAt,
};
}
async findByEmail(email: string): Promise<AuthIdentityDto> {
const identity = await this.repo.findOne({
email,
});
if (!identity) {
throw new IdentityNotFound();
}
return {
id: identity.id,
email: identity.email,
isVerified: identity.isVerified,
createdAt: identity.createdAt,
};
}
async getVerificationsByIdentityId(
identityId: string,
): Promise<AuthVerificationDto[]> {
const identity = await this.repo.findById(identityId);
if (!identity) {
return [];
}
return identity.verifications.map((v) => ({
id: v.id,
identityId: v.identityId,
magicToken: v.magicToken,
createdAt: v.createdAt,
acceptedAt: v.acceptedAt,
isAccepted: v.isAccepted,
isRevoked: v.isRevoked,
}));
}
async getIdentityIdFromVerificationId(
verificationId: string,
): Promise<string | null> {
let identityId: string | null = null;
// simulate separate verification repo/table
for (const user of this.repo.items) {
const verification = user.verifications.find(
(v) => v.id === verificationId,
);
if (verification) {
identityId = verification.identityId;
break;
}
}
if (identityId === null) return null;
// via the identity id, look through the identity table
for (const identity of this.repo.items) {
if (identity.id === identityId) {
return identity.id;
}
}
return null;
}
async getIdentityIdFromMagicToken(
magicToken: string,
): Promise<string | null> {
let identityId: string | null = null;
// simulate separate verification repo/table
for (const identity of this.repo.items) {
const verification = identity.verifications.find(
(v) => v.magicToken === magicToken,
);
if (verification) {
identityId = verification.identityId;
break;
}
}
return identityId;
}
}

View File

@@ -0,0 +1,7 @@
import type { AuthIdentityEntity } from "@/modules/auth/domain/auth-identity.entity.js";
import type { IAuthIdentityRepository } from "@/modules/auth/domain/auth-identity.repo.js";
import { InMemoryRepository } from "@/shared/infrastructure/persistence/fakes/InMemoryRepository.js";
export class AuthIdentityInMemoryRepository
extends InMemoryRepository<AuthIdentityEntity>
implements IAuthIdentityRepository {}

View File

@@ -1,38 +0,0 @@
import { inject, injectable } from "inversify";
import { UserEntity } from "@/modules/users/domain/users.entity.js";
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import type { IUseCase } from "@/shared/core/IUseCase.js";
export type UserSignupDTO = {
email: string;
password: string;
};
@injectable()
export class UserSignupUseCase implements IUseCase<UserSignupDTO> {
constructor(
@inject(UsersDomain.IUserRepository)
private readonly usersRepository: IUsersRepository,
@inject(SharedDomain.ICryptoService)
private readonly cryptoService: ICryptoService,
) {}
async execute(dto: UserSignupDTO): Promise<void> {
const user = await this.usersRepository.findOne({ email: dto.email });
if (user) {
throw new Error("User already exists");
}
const hashedPassword = await this.cryptoService.hashPassword(dto.password);
const userEntity = new UserEntity(
this.cryptoService.randomId(),
dto.email,
hashedPassword,
false,
new Date(),
);
await this.usersRepository.save(userEntity);
}
}

View File

@@ -17,13 +17,13 @@ const router = Router();
* type: string
*/
router.get("/", (req, res) => {
const { session, currentUser } = req;
const { session, currentIdentity } = req;
if (!session || !currentUser) {
if (!session || !currentIdentity) {
return res.send("Hello world! You are not logged in.");
}
res.send(`Hello ${currentUser.email}`);
res.send(`Hello ${currentIdentity.email}`);
});
export default router;

View File

@@ -0,0 +1 @@
export const UserDomain = {};

View File

@@ -0,0 +1,3 @@
import { ContainerModule } from "inversify";
// biome-ignore lint/correctness/noUnusedFunctionParameters: This bounded context is empty.
export const UserDIModule = new ContainerModule(({ bind }) => {});

View File

@@ -1,5 +0,0 @@
export class UserNotFound extends Error {
constructor() {
super("User not found");
}
}

View File

@@ -1,126 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
import { InvalidPassword } from "./errors/InvalidPassword.js";
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
import { UserEntity } from "./users.entity.js";
describe("Users - UserEntity", () => {
test("should create a user entity", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
expect(user).toBeDefined();
expect(user.id).toBe("1");
expect(user.email).toBe("test@example.com");
expect(user.password).toBe("password");
expect(user.isVerified).toBe(false);
expect(user.createdAt).toBeInstanceOf(Date);
});
test("should throw an error if the email is invalid", () => {
expect(() => {
new UserEntity("1", "test", "password", false, new Date());
}).toThrowError(InvalidEmailFormat);
});
test("should throw an error if the password is invalid", () => {
expect(() => {
new UserEntity("1", "test@example.com", "", false, new Date());
}).toThrowError(InvalidPassword);
});
test("should get account age in seconds", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 0));
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
// advance time by 5 seconds
vi.setSystemTime(new Date(2000, 1, 1, 13, 0, 5));
expect(user.getAccountAge()).toBe(5);
vi.useRealTimers();
});
test("should change email successfully", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
user.changeEmail("test2@example.com");
expect(user.email).toBe("test2@example.com");
});
test("should throw an error if the new email is invalid", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
expect(() => {
user.changeEmail("test");
}).toThrowError(InvalidEmailFormat);
});
test("should change password successfully", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
user.changePassword("password2");
expect(user.password).toBe("password2");
});
test("should throw an error if the new password is the same as the old password", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
expect(() => {
user.changePassword("password");
}).toThrowError(NewPasswordMustBeDifferent);
});
test("should throw an error if the new password is invalid", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
expect(() => {
user.changePassword("");
}).toThrowError(InvalidPassword);
});
test("should set verified status successfully", () => {
const user = new UserEntity(
"1",
"test@example.com",
"password",
false,
new Date(),
);
user.setVerifiedStatus(true);
expect(user.isVerified).toBe(true);
});
});

View File

@@ -1,4 +0,0 @@
import type { IBaseRepository } from "@/shared/core/IBaseRepository.js";
import type { UserEntity } from "./users.entity.js";
export interface IUsersRepository extends IBaseRepository<UserEntity> {}

View File

@@ -1,15 +0,0 @@
/** biome-ignore-all lint/suspicious/noEmptyInterface: This is a placeholder reference file. If your feature does not require a domain service, you can remove this file. */
/** biome-ignore-all lint/correctness/noUnusedPrivateClassMembers: This is a placeholder reference file. If your feature does not require a domain service, you can remove this file. */
import { inject, injectable } from "inversify";
import type { IUsersRepository } from "./users.repo.js";
import { UsersDomain } from "./users.symbols.js";
export interface IUsersService {}
@injectable()
export class UsersService implements IUsersService {
constructor(
@inject(UsersDomain.IUserRepository)
private readonly userRepo: IUsersRepository,
) {}
}

View File

@@ -1,4 +0,0 @@
export const UsersDomain = {
IUserRepository: Symbol.for("IUserRepository"),
IUserService: Symbol.for("IUsersService"),
};

View File

@@ -1,14 +0,0 @@
import { ContainerModule } from "inversify";
import type { IUsersRepository } from "../../domain/users.repo.js";
import type { IUsersService } from "../../domain/users.service.js";
import { UsersService } from "../../domain/users.service.js";
import { UsersDomain } from "../../domain/users.symbols.js";
import { RegisterUserUseCase } from "../../use-cases/register-user.js";
import { UsersPrismaRepository } from "../persistence/users.prisma.repo.js";
export const UsersDIModule = new ContainerModule(({ bind }) => {
bind<IUsersRepository>(UsersDomain.IUserRepository).to(UsersPrismaRepository);
bind<IUsersService>(UsersDomain.IUserService).to(UsersService);
bind(RegisterUserUseCase).toSelf().inTransientScope();
});

View File

@@ -1,7 +0,0 @@
import { InMemoryRepository } from "@/shared/infrastructure/persistence/fakes/InMemoryRepository.js";
import type { UserEntity } from "../../domain/users.entity.js";
import type { IUsersRepository } from "../../domain/users.repo.js";
export class UsersInMemoryRepository
extends InMemoryRepository<UserEntity>
implements IUsersRepository {}

View File

@@ -1,111 +0,0 @@
import { inject, injectable } from "inversify";
import type { User } from "@/generated/prisma/client.js";
import type { UserWhereInput } from "@/generated/prisma/models.js";
import type {
FilterCriteria,
PaginationOptions,
WithPagination,
} from "@/shared/core/IBaseRepository.js";
import {
type PrismaClient,
PrismaClientWrapper,
} from "@/shared/infrastructure/persistence/prisma/PrismaClientWrapper.js";
import { UserEntity } from "../../domain/users.entity.js";
import type { IUsersRepository } from "../../domain/users.repo.js";
/** MAPPERS */
export function fromDomain(userEntity: UserEntity): User {
return {
id: userEntity.id,
email: userEntity.email,
password: userEntity.password,
isVerified: userEntity.isVerified,
createdAt: userEntity.createdAt,
};
}
export function toDomain(userModel: User): UserEntity {
return new UserEntity(
userModel.id,
userModel.email,
userModel.password,
userModel.isVerified,
userModel.createdAt,
);
}
export function toUserModelFilter(
criteria: FilterCriteria<UserEntity>,
): FilterCriteria<User> {
const result: FilterCriteria<User> = {};
if (criteria.id !== undefined) result.id = criteria.id;
if (criteria.email !== undefined) result.email = criteria.email;
if (criteria.password !== undefined) result.password = criteria.password;
if (criteria.createdAt !== undefined) result.createdAt = criteria.createdAt;
return result;
}
@injectable()
export class UsersPrismaRepository implements IUsersRepository {
private readonly prisma: PrismaClient;
constructor(
@inject(PrismaClientWrapper)
private readonly prismaClientWrapper: PrismaClientWrapper,
) {
this.prisma = this.prismaClientWrapper.getClient();
}
async findOne(
criteria: FilterCriteria<UserEntity>,
): Promise<UserEntity | null> {
const where: UserWhereInput = {};
const modelFilter = toUserModelFilter(criteria);
if (modelFilter.id) {
where.id = modelFilter.id;
}
if (modelFilter.email) {
where.email = modelFilter.email;
}
const model = await this.prisma.user.findFirst({ where });
return model ? toDomain(model) : null;
}
async findById(id: string): Promise<UserEntity | null> {
const row = await this.prisma.user.findUnique({ where: { id } });
return row ? toDomain(row) : null;
}
async findAll(
criteria?: FilterCriteria<UserEntity>,
paginationOptions?: PaginationOptions,
): Promise<WithPagination<UserEntity>> {
const where: UserWhereInput = {};
const modelFilter = criteria ? toUserModelFilter(criteria) : {};
if (modelFilter.id) {
where.id = modelFilter.id;
}
if (modelFilter.email) {
where.email = modelFilter.email;
}
const models = paginationOptions
? await this.prisma.user.findMany({
where,
take: paginationOptions.limit,
skip: paginationOptions.offset,
})
: await this.prisma.user.findMany({ where });
const total = await this.prisma.user.count({ where });
return {
data: models.map(toDomain),
total,
};
}
async save(entity: UserEntity): Promise<UserEntity | null> {
const model = await this.prisma.user.upsert({
where: { id: entity.id },
create: fromDomain(entity),
update: fromDomain(entity),
});
return model ? toDomain(model) : null;
}
generateId(): string {
return this.prismaClientWrapper.generateId();
}
}

View File

@@ -1,5 +0,0 @@
import { expect, test } from "vitest";
test("adds 1 + 2 to equal 3", () => {
expect(1 + 2).toBe(3);
});

View File

@@ -1,41 +0,0 @@
import { inject, injectable } from "inversify";
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import type { IUseCase } from "@/shared/core/IUseCase.js";
import { UserEntity } from "../domain/users.entity.js";
import type { IUsersRepository } from "../domain/users.repo.js";
import { UsersDomain } from "../domain/users.symbols.js";
export type RegisterUserDTO = {
email: string;
password: string;
isVerified?: boolean;
};
@injectable()
export class RegisterUserUseCase implements IUseCase<RegisterUserDTO> {
constructor(
@inject(UsersDomain.IUserRepository)
private readonly userRepo: IUsersRepository,
@inject(SharedDomain.ICryptoService)
private readonly cryptoService: ICryptoService,
) {}
async execute(inputDto: RegisterUserDTO): Promise<void> {
const user = await this.userRepo.findOne({ email: inputDto.email });
if (user) {
throw new Error("User already exists");
}
const hashedPassword = await this.cryptoService.hashPassword(
inputDto.password,
);
const newUser = new UserEntity(
this.userRepo.generateId(),
inputDto.email,
hashedPassword,
inputDto.isVerified ?? false,
new Date(),
);
await this.userRepo.save(newUser);
}
}

View File

@@ -1,18 +1,21 @@
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
import type { AuthIdentityDto } from "@/modules/auth/application/query-service.js";
export interface ISession {
userId: string;
identityId: string;
email: string;
isVerified: boolean;
loginDate: Date;
}
export interface IRefreshData {
userId: string;
identityId: string;
}
export interface ITokenService {
generateToken(user: UserEntity): string;
generateRefreshToken(user: UserEntity): string;
generateToken(
identity: AuthIdentityDto,
additionalClaims?: Record<string, string | boolean | number>,
): string;
generateRefreshToken(identity: AuthIdentityDto): string;
getSession(token: string): ISession | null;
validateRefreshToken(refreshToken: string): IRefreshData | null;
}

View File

@@ -0,0 +1,7 @@
/**
* Extracts only the non-function properties from a class type.
*/
/** biome-ignore-all lint/suspicious/noExplicitAny: Any is required to catch all callables. */
export type DataOnlyDto<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
};

View File

@@ -1,3 +1,5 @@
import type { DataOnlyDto } from "./DataOnlyDto.js";
export type FilterRange<T> = {
from?: T;
to?: T;
@@ -15,7 +17,7 @@ type FilterValue<T> = T extends string
: never; // Exclude types that are not string, number, Date, or boolean
export type FilterCriteria<T> = {
[K in keyof T]?: FilterValue<T[K]> | undefined;
[K in keyof DataOnlyDto<T>]?: FilterValue<DataOnlyDto<T>[K]> | undefined;
};
export type PaginationOptions = {
@@ -23,7 +25,7 @@ export type PaginationOptions = {
limit: number;
};
export type WithPagination<T> = {
export type PaginationResult<T> = {
data: T[];
total: number;
};
@@ -34,7 +36,7 @@ export interface IBaseRepository<T> {
findAll(
criteria?: FilterCriteria<T>,
paginationOptions?: PaginationOptions,
): Promise<WithPagination<T>>;
): Promise<PaginationResult<T>>;
save(entity: T): Promise<T | null>;
generateId(): string;
}

View File

@@ -0,0 +1,9 @@
export interface IMapper<TDomainEntity, TPersistenceModel> {
toDomain(model: TPersistenceModel): TDomainEntity;
toModel(entity: TDomainEntity): TPersistenceModel;
}
export interface IAsyncMapper<TDomainEntity, TPersistenceModel> {
toDomain(model: TPersistenceModel): Promise<TDomainEntity>;
toModel(entity: TDomainEntity): Promise<TPersistenceModel>;
}

View File

@@ -0,0 +1,15 @@
import type { IBaseRepository } from "./IBaseRepository.js";
export class LazyRelation<
TEntity,
TRepository extends IBaseRepository<TEntity>,
> {
constructor(
public readonly id: string,
private readonly source: TRepository,
) {}
async get(): Promise<TEntity | null> {
return this.source.findById(this.id);
}
}

View File

@@ -0,0 +1,24 @@
import type { FilterCriteria, IBaseRepository } from "./IBaseRepository.js";
type BaseEntity = {
id: string;
};
export class LazyRelationMany<
TEntity extends BaseEntity,
TRepository extends IBaseRepository<TEntity>,
> {
constructor(
public readonly id: string,
public readonly targetId: keyof TEntity,
private readonly source: TRepository,
) {}
async get(): Promise<TEntity[]> {
return (
await this.source.findAll({
[this.targetId]: this.id,
} as FilterCriteria<TEntity>)
).data;
}
}

View File

@@ -1,7 +1,7 @@
import { inject, injectable } from "inversify";
import jwt from "jsonwebtoken";
import z from "zod";
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
import type { AuthIdentityDto } from "@/modules/auth/application/query-service.js";
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
import type { ILogger } from "@/shared/application/ports/ILogger.js";
import type {
@@ -34,7 +34,7 @@ export class JwtService implements ITokenService {
) {}
generateToken(
user: UserEntity,
identity: AuthIdentityDto,
additionalClaims?: Record<string, string | boolean | number>,
): string {
const duration = Number.parseInt(
@@ -45,9 +45,9 @@ export class JwtService implements ITokenService {
const claims = {
iat: Math.ceil(Date.now() / 1000),
exp: Math.ceil(Date.now() / 1000) + duration,
sub: user.id,
email: user.email,
isVerified: user.isVerified,
sub: identity.id,
email: identity.email,
isVerified: identity.isVerified,
...additionalClaims,
};
const jwtClaims = JWTSessionSchema.parse(claims);
@@ -57,7 +57,7 @@ export class JwtService implements ITokenService {
});
return token;
}
generateRefreshToken(user: UserEntity): string {
generateRefreshToken(identity: AuthIdentityDto): string {
const duration = Number.parseInt(
this.configService.get("JWT_REFRESH_DURATION"),
10,
@@ -66,7 +66,7 @@ export class JwtService implements ITokenService {
const claims = {
iat: Math.ceil(Date.now() / 1000),
exp: Math.ceil(Date.now() / 1000) + duration,
sub: user.id,
sub: identity.id,
};
const jwtClaims = JWTRefreshSchema.parse(claims);
const token = jwt.sign(jwtClaims, secret, {
@@ -89,7 +89,7 @@ export class JwtService implements ITokenService {
return null;
}
const session = {
userId: decodedToken.sub,
identityId: decodedToken.sub,
email: decodedToken.email,
isVerified: decodedToken.isVerified,
loginDate: new Date(decodedToken.iat * 1000),
@@ -123,7 +123,7 @@ export class JwtService implements ITokenService {
return null;
}
const refreshData = {
userId: decodedToken.sub,
identityId: decodedToken.sub,
};
return refreshData;
} catch (error) {

View File

@@ -1,12 +1,12 @@
import { Container } from "inversify";
import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js";
import { UsersDIModule } from "@/modules/users/infrastructure/di/users.di.js";
import { UserDIModule } from "@/modules/user/infrastructure/di/user.di.js";
import { SharedDIModule } from "./shared.di.js";
const appContainer = new Container();
appContainer.load(SharedDIModule);
appContainer.load(AuthDIModule);
appContainer.load(UsersDIModule);
appContainer.load(UserDIModule);
export { appContainer };

View File

@@ -1,6 +1,6 @@
import type { NextFunction, Request, Response } from "express";
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
import type { IAuthQueryService } from "@/modules/auth/application/query-service.js";
import { AuthDomain } from "@/modules/auth/domain/auth.symbols.js";
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
import { appContainer } from "../../di/Container.js";
@@ -13,8 +13,8 @@ export const attachSession = async (
const tokenService = appContainer.get<ITokenService>(
SharedDomain.ITokenService,
);
const userRepo = appContainer.get<IUsersRepository>(
UsersDomain.IUserRepository,
const authQueryService = appContainer.get<IAuthQueryService>(
AuthDomain.IAuthIdentityQueryService,
);
const token = req.cookies.token;
if (!token) {
@@ -26,12 +26,12 @@ export const attachSession = async (
next();
return;
}
const currentUser = await userRepo.findOne({ id: session.userId });
if (!currentUser) {
const currentIdentity = await authQueryService.findById(session.identityId);
if (!currentIdentity) {
next();
return;
}
req.session = session;
req.currentUser = currentUser;
req.currentIdentity = currentIdentity;
next();
};

View File

@@ -1,9 +1,9 @@
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
import type { AuthIdentityDto } from "@/modules/auth/application/query-service.js";
import type { ISession } from "@/shared/application/ports/ITokenService.js";
declare module "express-serve-static-core" {
interface Request {
session?: ISession;
currentUser?: UserEntity;
currentIdentity?: AuthIdentityDto;
}
}

View File

@@ -1,4 +1,5 @@
import type {
ErrorLogMessage,
ILogger,
LogMessage,
} from "@/shared/application/ports/ILogger.js";
@@ -20,8 +21,9 @@ export class ConsoleLogger implements ILogger {
console.log(messageBuilder(message));
}
error(message: LogMessage): void {
error(message: ErrorLogMessage): void {
console.error(messageBuilder(message));
console.error(message.error);
}
warn(message: LogMessage): void {

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import { InMemoryRepository } from "./InMemoryRepository.js";
type TestEntity = {
@@ -18,385 +18,479 @@ describe("InMemoryRepository (Generic) - Comprehensive Tests", () => {
repo = new TestRepository();
});
describe("Persistence Operations", () => {
it("should save and find an entity by ID", async () => {
const id = repo.generateId();
const entity: TestEntity = {
id,
name: "Test",
age: 25,
isActive: true,
createdAt: new Date(),
};
test("Persistence Operations - should save and find an entity by ID", async () => {
const id = repo.generateId();
const entity: TestEntity = {
id,
name: "Test",
age: 25,
isActive: true,
createdAt: new Date(),
};
await repo.save(entity);
const found = await repo.findById(id);
await repo.save(entity);
const found = await repo.findById(id);
expect(found).toEqual(entity);
});
it("should update an existing entity with the same ID", async () => {
const id = repo.generateId();
const entity: TestEntity = {
id,
name: "Old Name",
age: 25,
isActive: true,
createdAt: new Date(),
};
await repo.save(entity);
// Modify and save again
entity.name = "New Name";
await repo.save(entity);
const found = await repo.findById(id);
expect(found).toBeDefined();
expect(found?.name).toBe("New Name");
// Ensure no duplicate records
const all = await repo.findAll();
expect(all.total).toBe(1);
});
it("should return null if finding by non-existent ID", async () => {
const found = await repo.findById("non-existent-id");
expect(found).toBeNull();
});
expect(found).toEqual(entity);
});
describe("Query Operations", () => {
it("should find one entity by filter criteria", async () => {
await repo.save({
id: "1",
name: "Unique",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Other",
age: 30,
isActive: false,
createdAt: new Date(),
});
test("Persistence Operations - should update an existing entity with the same ID", async () => {
const id = repo.generateId();
const entity: TestEntity = {
id,
name: "Old Name",
age: 25,
isActive: true,
createdAt: new Date(),
};
const found = await repo.findOne({ name: "Unique" });
expect(found).toBeDefined();
expect(found?.id).toBe("1");
});
await repo.save(entity);
it("should return null if findOne matches nothing", async () => {
const found = await repo.findOne({ name: "NonExistent" });
expect(found).toBeNull();
});
// Modify and save again
entity.name = "New Name";
await repo.save(entity);
const found = await repo.findById(id);
expect(found).toBeDefined();
expect(found?.name).toBe("New Name");
// Ensure no duplicate records
const all = await repo.findAll();
expect(all.total).toBe(1);
});
describe("Exact Match Filtering", () => {
it("should filter by string (exact match)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ name: "Alice" });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.name).toBe("Alice");
});
it("should filter by number (exact match)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ age: 25 });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.age).toBe(25);
});
it("should filter by boolean (true)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ isActive: true });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.isActive).toBe(true);
});
it("should filter by boolean (false)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ isActive: false });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.isActive).toBe(false);
});
it("should filter by Date (exact match)", async () => {
const date1 = new Date("2023-01-01T00:00:00Z");
const date2 = new Date("2023-01-02T00:00:00Z");
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: date1,
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: date2,
});
const result = await repo.findAll({ createdAt: date1 });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.createdAt).toEqual(date1);
});
it("should ignore undefined filter properties", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ name: undefined });
expect(result.data).toHaveLength(2);
});
test("Persistence Operations - should return null if finding by non-existent ID", async () => {
const found = await repo.findById("non-existent-id");
expect(found).toBeNull();
});
describe("Range Filtering - Number", () => {
beforeEach(async () => {
await repo.save({
id: "1",
name: "Kid",
age: 10,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Teen",
age: 15,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "3",
name: "Adult",
age: 25,
isActive: true,
createdAt: new Date(),
});
test("Query Operations - should find one entity by filter criteria", async () => {
await repo.save({
id: "1",
name: "Unique",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Other",
age: 30,
isActive: false,
createdAt: new Date(),
});
it("should filter by number range (from only - tail case)", async () => {
// age >= 15
const result = await repo.findAll({ age: { from: 15 } });
expect(result.data).toHaveLength(2); // Teen, Adult
expect(result.data.map((i) => i.age).sort()).toEqual([15, 25]);
});
it("should filter by number range (to only - head case)", async () => {
// age <= 15
const result = await repo.findAll({ age: { to: 15 } });
expect(result.data).toHaveLength(2); // Kid, Teen
expect(result.data.map((i) => i.age).sort()).toEqual([10, 15]);
});
it("should filter by number range (from and to - middle case)", async () => {
// 12 <= age <= 20
const result = await repo.findAll({ age: { from: 12, to: 20 } });
expect(result.data).toHaveLength(1); // Teen
expect(result.data.at(0)?.age).toBe(15);
});
const found = await repo.findOne({ name: "Unique" });
expect(found).toBeDefined();
expect(found?.id).toBe("1");
});
describe("Range Filtering - Date", () => {
test("Query Operations - should return null if findOne matches nothing", async () => {
const found = await repo.findOne({ name: "NonExistent" });
expect(found).toBeNull();
});
test("Exact Match Filtering - should filter by string (exact match)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ name: "Alice" });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.name).toBe("Alice");
});
test("Exact Match Filtering - should filter by number (exact match)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ age: 25 });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.age).toBe(25);
});
test("Exact Match Filtering - should filter by boolean (true)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ isActive: true });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.isActive).toBe(true);
});
test("Exact Match Filtering - should filter by boolean (false)", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ isActive: false });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.isActive).toBe(false);
});
test("Exact Match Filtering - should filter by Date (exact match)", async () => {
const date1 = new Date("2023-01-01T00:00:00Z");
const date2 = new Date("2023-01-02T00:00:00Z");
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: date1,
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: date2,
});
const result = await repo.findAll({ createdAt: date1 });
expect(result.data).toHaveLength(1);
expect(result.data.at(0)?.createdAt).toEqual(date1);
});
test("Exact Match Filtering - should ignore undefined filter properties", async () => {
await repo.save({
id: "1",
name: "Alice",
age: 25,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Bob",
age: 30,
isActive: false,
createdAt: new Date(),
});
const result = await repo.findAll({ name: undefined });
expect(result.data).toHaveLength(2);
});
test("Range Filtering - Number - should filter by number range (from only - tail case)", async () => {
await repo.save({
id: "1",
name: "Kid",
age: 10,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Teen",
age: 15,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "3",
name: "Adult",
age: 25,
isActive: true,
createdAt: new Date(),
});
// age >= 15
const result = await repo.findAll({ age: { from: 15 } });
expect(result.data).toHaveLength(2); // Teen, Adult
expect(result.data.map((i) => i.age).sort()).toEqual([15, 25]);
});
test("Range Filtering - Number - should filter by number range (to only - head case)", async () => {
await repo.save({
id: "1",
name: "Kid",
age: 10,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Teen",
age: 15,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "3",
name: "Adult",
age: 25,
isActive: true,
createdAt: new Date(),
});
// age <= 15
const result = await repo.findAll({ age: { to: 15 } });
expect(result.data).toHaveLength(2); // Kid, Teen
expect(result.data.map((i) => i.age).sort()).toEqual([10, 15]);
});
test("Range Filtering - Number - should filter by number range (from and to - middle case)", async () => {
await repo.save({
id: "1",
name: "Kid",
age: 10,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "2",
name: "Teen",
age: 15,
isActive: true,
createdAt: new Date(),
});
await repo.save({
id: "3",
name: "Adult",
age: 25,
isActive: true,
createdAt: new Date(),
});
// 12 <= age <= 20
const result = await repo.findAll({ age: { from: 12, to: 20 } });
expect(result.data).toHaveLength(1); // Teen
expect(result.data.at(0)?.age).toBe(15);
});
test("Range Filtering - Date - should filter by date range (from only - tail case)", async () => {
const d1 = new Date("2023-01-01T10:00:00Z");
const d2 = new Date("2023-01-02T10:00:00Z");
const d3 = new Date("2023-01-03T10:00:00Z");
beforeEach(async () => {
await repo.save({
id: "1",
name: "First",
age: 20,
isActive: true,
createdAt: d1,
});
await repo.save({
id: "2",
name: "Second",
age: 20,
isActive: true,
createdAt: d2,
});
await repo.save({
id: "3",
name: "Third",
age: 20,
isActive: true,
createdAt: d3,
});
await repo.save({
id: "1",
name: "First",
age: 20,
isActive: true,
createdAt: d1,
});
await repo.save({
id: "2",
name: "Second",
age: 20,
isActive: true,
createdAt: d2,
});
await repo.save({
id: "3",
name: "Third",
age: 20,
isActive: true,
createdAt: d3,
});
it("should filter by date range (from only - tail case)", async () => {
// date >= d2
const result = await repo.findAll({ createdAt: { from: d2 } });
expect(result.data).toHaveLength(2); // Second, Third
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
d2.getTime(),
d3.getTime(),
]);
});
it("should filter by date range (to only - head case)", async () => {
// date <= d2
const result = await repo.findAll({ createdAt: { to: d2 } });
expect(result.data).toHaveLength(2); // First, Second
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
d1.getTime(),
d2.getTime(),
]);
});
it("should filter by date range (from and to - middle case)", async () => {
// d1 <= date <= d2
const result = await repo.findAll({ createdAt: { from: d1, to: d2 } });
expect(result.data).toHaveLength(2); // First, Second
});
// date >= d2
const result = await repo.findAll({ createdAt: { from: d2 } });
expect(result.data).toHaveLength(2); // Second, Third
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
d2.getTime(),
d3.getTime(),
]);
});
describe("Pagination with Filtering", () => {
beforeEach(async () => {
// Create 10 items
for (let i = 1; i <= 10; i++) {
await repo.save({
id: i.toString(),
name: i % 2 === 0 ? "Even" : "Odd",
age: i * 10, // 10, 20, ..., 100
isActive: true,
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
});
}
test("Range Filtering - Date - should filter by date range (to only - head case)", async () => {
const d1 = new Date("2023-01-01T10:00:00Z");
const d2 = new Date("2023-01-02T10:00:00Z");
const d3 = new Date("2023-01-03T10:00:00Z");
await repo.save({
id: "1",
name: "First",
age: 20,
isActive: true,
createdAt: d1,
});
await repo.save({
id: "2",
name: "Second",
age: 20,
isActive: true,
createdAt: d2,
});
await repo.save({
id: "3",
name: "Third",
age: 20,
isActive: true,
createdAt: d3,
});
it("should paginate exact match results", async () => {
// Filter: name = "Even" (5 items: 2, 4, 6, 8, 10)
// Page 1: limit 2 -> [2, 4]
const page1 = await repo.findAll(
{ name: "Even" },
{ offset: 0, limit: 2 },
);
expect(page1.total).toBe(5);
expect(page1.data).toHaveLength(2);
expect(page1.data.at(0)?.id).toBe("2");
expect(page1.data.at(1)?.id).toBe("4");
// Page 2: offset 2, limit 2 -> [6, 8]
const page2 = await repo.findAll(
{ name: "Even" },
{ offset: 2, limit: 2 },
);
expect(page2.data).toHaveLength(2);
expect(page2.data.at(0)?.id).toBe("6");
expect(page2.data.at(1)?.id).toBe("8");
});
it("should paginate number range results", async () => {
// Filter: age >= 50 (6 items: 50, 60, 70, 80, 90, 100)
// Page 1: limit 3 -> [50, 60, 70]
const result = await repo.findAll(
{ age: { from: 50 } },
{ offset: 0, limit: 3 },
);
expect(result.total).toBe(6);
expect(result.data).toHaveLength(3);
expect(result.data.map((i) => i.age)).toEqual([50, 60, 70]);
});
it("should paginate date range results", async () => {
// Filter: date <= 2023-01-05 (5 items: 1, 2, 3, 4, 5)
const targetDate = new Date("2023-01-05");
// Page 2: offset 2, limit 2 -> [3, 4]
const result = await repo.findAll(
{ createdAt: { to: targetDate } },
{ offset: 2, limit: 2 },
);
expect(result.total).toBe(5);
expect(result.data).toHaveLength(2);
expect(result.data.at(0)?.id).toBe("3");
expect(result.data.at(1)?.id).toBe("4");
});
// date <= d2
const result = await repo.findAll({ createdAt: { to: d2 } });
expect(result.data).toHaveLength(2); // First, Second
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
d1.getTime(),
d2.getTime(),
]);
});
describe("Utility Operations", () => {
it("should generate unique IDs", () => {
const ids = new Set<string>();
for (let i = 0; i < 1000; i++) {
ids.add(repo.generateId());
}
expect(ids.size).toBe(1000);
test("Range Filtering - Date - should filter by date range (from and to - middle case)", async () => {
const d1 = new Date("2023-01-01T10:00:00Z");
const d2 = new Date("2023-01-02T10:00:00Z");
const d3 = new Date("2023-01-03T10:00:00Z");
await repo.save({
id: "1",
name: "First",
age: 20,
isActive: true,
createdAt: d1,
});
await repo.save({
id: "2",
name: "Second",
age: 20,
isActive: true,
createdAt: d2,
});
await repo.save({
id: "3",
name: "Third",
age: 20,
isActive: true,
createdAt: d3,
});
// d1 <= date <= d2
const result = await repo.findAll({ createdAt: { from: d1, to: d2 } });
expect(result.data).toHaveLength(2); // First, Second
expect(result.data.at(0)?.id).toBe("1");
expect(result.data.at(1)?.id).toBe("2");
});
test("Pagination with Filtering - should paginate exact match results", async () => {
// Create 10 items
for (let i = 1; i <= 10; i++) {
await repo.save({
id: i.toString(),
name: i % 2 === 0 ? "Even" : "Odd",
age: i * 10, // 10, 20, ..., 100
isActive: true,
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
});
}
// Filter: name = "Even" (5 items: 2, 4, 6, 8, 10)
// Page 1: limit 2 -> [2, 4]
const page1 = await repo.findAll({ name: "Even" }, { offset: 0, limit: 2 });
expect(page1.total).toBe(5);
expect(page1.data).toHaveLength(2);
expect(page1.data.at(0)?.id).toBe("2");
expect(page1.data.at(1)?.id).toBe("4");
// Page 2: offset 2, limit 2 -> [6, 8]
const page2 = await repo.findAll({ name: "Even" }, { offset: 2, limit: 2 });
expect(page2.data).toHaveLength(2);
expect(page2.data.at(0)?.id).toBe("6");
expect(page2.data.at(1)?.id).toBe("8");
});
test("Pagination with Filtering - should paginate number range results", async () => {
// Create 10 items
for (let i = 1; i <= 10; i++) {
await repo.save({
id: i.toString(),
name: i % 2 === 0 ? "Even" : "Odd",
age: i * 10, // 10, 20, ..., 100
isActive: true,
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
});
}
// Filter: age >= 50 (6 items: 50, 60, 70, 80, 90, 100)
// Page 1: limit 3 -> [50, 60, 70]
const result = await repo.findAll(
{ age: { from: 50 } },
{ offset: 0, limit: 3 },
);
expect(result.total).toBe(6);
expect(result.data).toHaveLength(3);
expect(result.data.map((i) => i.age)).toEqual([50, 60, 70]);
});
test("Pagination with Filtering - should paginate date range results", async () => {
// Create 10 items
for (let i = 1; i <= 10; i++) {
await repo.save({
id: i.toString(),
name: i % 2 === 0 ? "Even" : "Odd",
age: i * 10, // 10, 20, ..., 100
isActive: true,
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
});
}
// Filter: date <= 2023-01-05 (5 items: 1, 2, 3, 4, 5)
const targetDate = new Date("2023-01-05");
// Page 2: offset 2, limit 2 -> [3, 4]
const result = await repo.findAll(
{ createdAt: { to: targetDate } },
{ offset: 2, limit: 2 },
);
expect(result.total).toBe(5);
expect(result.data).toHaveLength(2);
expect(result.data.at(0)?.id).toBe("3");
expect(result.data.at(1)?.id).toBe("4");
});
test("Utility Operations - should generate unique IDs", () => {
const ids = new Set<string>();
for (let i = 0; i < 1000; i++) {
ids.add(repo.generateId());
}
expect(ids.size).toBe(1000);
});
});

View File

@@ -4,13 +4,13 @@ import type {
FilterRange,
IBaseRepository,
PaginationOptions,
WithPagination,
PaginationResult,
} from "@/shared/core/IBaseRepository.js";
export class InMemoryRepository<T extends { id: string }>
implements IBaseRepository<T>
{
protected items: T[] = [];
public items: T[] = [];
async save(entity: T): Promise<T | null> {
const index = this.items.findIndex((item) => item.id === entity.id);
@@ -34,7 +34,7 @@ export class InMemoryRepository<T extends { id: string }>
async findAll(
criteria?: FilterCriteria<T>,
paginationOptions?: PaginationOptions,
): Promise<WithPagination<T>> {
): Promise<PaginationResult<T>> {
let filtered = this.items;
if (criteria) {

View File

@@ -1,15 +1,15 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { inject, injectable } from "inversify";
import { uuidv7 } from "uuidv7";
import { PrismaClient as PrismaClientLib } from "@/generated/prisma/client.js";
import { PrismaClient } from "@/generated/prisma/client.js";
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
export type PrismaClient = PrismaClientLib;
export { PrismaClient };
@injectable()
export class PrismaClientWrapper {
private readonly client: PrismaClientLib;
private readonly client: PrismaClient;
constructor(
@inject(SharedDomain.IConfigService)
@@ -21,12 +21,12 @@ export class PrismaClientWrapper {
connectionString,
});
this.client = new PrismaClientLib({
this.client = new PrismaClient({
adapter,
});
}
getClient(): PrismaClientLib {
getClient(): PrismaClient {
return this.client;
}

View File

@@ -4,7 +4,7 @@ const config = {
definition: {
openapi: "3.0.0",
info: {
title: "Express-Starter",
title: "Cedar CMS",
version: "1.0.0",
},
},

View File

@@ -47,6 +47,6 @@
"exclude": [
"prisma.config.ts",
"vitest.config.ts",
"dist"
"dist",
]
}