feat(auth): setup base auth domain
This commit is contained in:
40
README.md
40
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
21
prisma/schema/auth.prisma
Normal 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])
|
||||
}
|
||||
36
prisma/schema/organizations.prisma
Normal file
36
prisma/schema/organizations.prisma
Normal 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
|
||||
}
|
||||
9
prisma/schema/schema.prisma
Normal file
9
prisma/schema/schema.prisma
Normal file
@@ -0,0 +1,9 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../../src/generated/prisma"
|
||||
previewFeatures = ["relationJoins"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
30
src/modules/auth/application/query-service.ts
Normal file
30
src/modules/auth/application/query-service.ts
Normal 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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
42
src/modules/auth/application/use-cases/create-identity.ts
Normal file
42
src/modules/auth/application/use-cases/create-identity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
203
src/modules/auth/domain/auth-identity.entity.spec.ts
Normal file
203
src/modules/auth/domain/auth-identity.entity.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
5
src/modules/auth/domain/auth-identity.repo.ts
Normal file
5
src/modules/auth/domain/auth-identity.repo.ts
Normal 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> {}
|
||||
145
src/modules/auth/domain/auth-verifications.entity.spec.ts
Normal file
145
src/modules/auth/domain/auth-verifications.entity.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
33
src/modules/auth/domain/auth-verifications.entity.ts
Normal file
33
src/modules/auth/domain/auth-verifications.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/modules/auth/domain/auth.symbols.ts
Normal file
5
src/modules/auth/domain/auth.symbols.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const AuthDomain = {
|
||||
// IAuthIdentityGateway: Symbol.for("IAuthIdentityGateway"),
|
||||
IAuthIdentityRepository: Symbol.for("IAuthIdentityRepository"),
|
||||
IAuthIdentityQueryService: Symbol.for("IAuthIdentityQueryService"),
|
||||
};
|
||||
5
src/modules/auth/domain/errors/IdentityAlreadyExists.ts
Normal file
5
src/modules/auth/domain/errors/IdentityAlreadyExists.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class IdentityAlreadyExists extends Error {
|
||||
constructor() {
|
||||
super("Identity already exists");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class IdentityAlreadyVerified extends Error {
|
||||
constructor() {
|
||||
super("Identity already verified");
|
||||
}
|
||||
}
|
||||
5
src/modules/auth/domain/errors/IdentityNotFound.ts
Normal file
5
src/modules/auth/domain/errors/IdentityNotFound.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class IdentityNotFound extends Error {
|
||||
constructor() {
|
||||
super("Identity not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class InvalidCredentials extends Error {
|
||||
constructor() {
|
||||
super("Invalid credentails.");
|
||||
}
|
||||
}
|
||||
|
||||
5
src/modules/auth/domain/errors/InvalidMagicToken.ts
Normal file
5
src/modules/auth/domain/errors/InvalidMagicToken.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class InvalidMagicToken extends Error {
|
||||
constructor() {
|
||||
super("Invalid magic token");
|
||||
}
|
||||
}
|
||||
5
src/modules/auth/domain/errors/InvalidSession.ts
Normal file
5
src/modules/auth/domain/errors/InvalidSession.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class InvalidSession extends Error {
|
||||
constructor() {
|
||||
super("Invalid session.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class VerificationAlreadyAccepted extends Error {
|
||||
constructor() {
|
||||
super("Verification was already accepted.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class VerificationAlreadyRevoked extends Error {
|
||||
constructor() {
|
||||
super("Verification was already revoked.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
190
src/modules/auth/infrastructure/persistence/auth.prisma.repo.ts
Normal file
190
src/modules/auth/infrastructure/persistence/auth.prisma.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1
src/modules/user/domain/user.symbols.ts
Normal file
1
src/modules/user/domain/user.symbols.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const UserDomain = {};
|
||||
3
src/modules/user/infrastructure/di/user.di.ts
Normal file
3
src/modules/user/infrastructure/di/user.di.ts
Normal 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 }) => {});
|
||||
@@ -1,5 +0,0 @@
|
||||
export class UserNotFound extends Error {
|
||||
constructor() {
|
||||
super("User not found");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> {}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const UsersDomain = {
|
||||
IUserRepository: Symbol.for("IUserRepository"),
|
||||
IUserService: Symbol.for("IUsersService"),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
test("adds 1 + 2 to equal 3", () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
7
src/shared/core/DataOnlyDto.ts
Normal file
7
src/shared/core/DataOnlyDto.ts
Normal 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];
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
9
src/shared/core/IMapper.ts
Normal file
9
src/shared/core/IMapper.ts
Normal 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>;
|
||||
}
|
||||
15
src/shared/core/LazyRelation.ts
Normal file
15
src/shared/core/LazyRelation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/shared/core/LazyRelationMany.ts
Normal file
24
src/shared/core/LazyRelationMany.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const config = {
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Express-Starter",
|
||||
title: "Cedar CMS",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,6 +47,6 @@
|
||||
"exclude": [
|
||||
"prisma.config.ts",
|
||||
"vitest.config.ts",
|
||||
"dist"
|
||||
"dist",
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user