feat(auth): setup base auth domain
This commit is contained in:
40
README.md
40
README.md
@@ -1,31 +1,4 @@
|
|||||||
# Express Starter Template
|
# Cedar CMS (Backend Monolith)
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 🏁 Getting Started
|
## 🏁 Getting Started
|
||||||
|
|
||||||
@@ -40,7 +13,7 @@ The `inversify-express-utils` package is already deprecated so the focus should
|
|||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd express-starter
|
cd cedar-api
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
@@ -51,10 +24,10 @@ The `inversify-express-utils` package is already deprecated so the focus should
|
|||||||
3. Set up environment variables:
|
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).
|
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:
|
4. Setup the database with the codebase's schema:
|
||||||
> Note: Run this command every time you make changes to the Prisma schema.
|
> Note: Run `yarn prisma:migrate` every time you make changes to the Prisma schema.
|
||||||
```bash
|
```bash
|
||||||
yarn prisma:migrate
|
yarn prisma:push
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Generate Prisma Client:
|
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 format`: Format the codebase using Biome.
|
||||||
- `yarn test`: Run unit tests using Vitest.
|
- `yarn test`: Run unit tests using Vitest.
|
||||||
- `yarn coverage`: Run tests with coverage reporting.
|
- `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
|
## 🧪 Testing
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "express-starter",
|
"name": "cedar-api",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:push": "prisma db push",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "dotenv/config";
|
|||||||
import { defineConfig, env } from "prisma/config";
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/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 { 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 { 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", () => {
|
describe("Auth - Create identity", () => {
|
||||||
let usersRepo: UsersInMemoryRepository;
|
let identityRepo: AuthIdentityInMemoryRepository;
|
||||||
const MockCryptoService = vi.fn(
|
const MockCryptoService = vi.fn(
|
||||||
class implements ICryptoService {
|
class implements ICryptoService {
|
||||||
randomId = vi.fn().mockReturnValue("1");
|
randomId = vi.fn().mockReturnValue("1");
|
||||||
@@ -14,20 +15,20 @@ describe("Auth - User signup", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const cryptoService: ICryptoService = new MockCryptoService();
|
const cryptoService: ICryptoService = new MockCryptoService();
|
||||||
let useCase: UserSignupUseCase;
|
let useCase: CreateIdentityUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
usersRepo = new UsersInMemoryRepository();
|
identityRepo = new AuthIdentityInMemoryRepository();
|
||||||
useCase = new UserSignupUseCase(usersRepo, cryptoService);
|
useCase = new CreateIdentityUseCase(identityRepo, cryptoService);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should signup a user", async () => {
|
test("should create an identity", async () => {
|
||||||
const saveSpy = vi.spyOn(usersRepo, "save");
|
const saveSpy = vi.spyOn(identityRepo, "save");
|
||||||
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
const findOneSpy = vi.spyOn(identityRepo, "findOne");
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
password: "password",
|
password: "password",
|
||||||
@@ -39,37 +40,36 @@ describe("Auth - User signup", () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await usersRepo.findAll({
|
await identityRepo.findAll({
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
})
|
})
|
||||||
).data,
|
).data,
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await usersRepo.findAll({
|
await identityRepo.findAll({
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
})
|
})
|
||||||
).data,
|
).data,
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw an error if the user already exists", async () => {
|
test("should throw an error if an identity with the same email exists", async () => {
|
||||||
// setup
|
await identityRepo.save(
|
||||||
await usersRepo.save(
|
new AuthIdentityEntity(
|
||||||
new UserEntity(
|
|
||||||
"1",
|
"1",
|
||||||
"test@example.com",
|
"test@example.com",
|
||||||
"hashed-password",
|
"hashed-password",
|
||||||
true,
|
true,
|
||||||
new Date(),
|
new Date(),
|
||||||
|
[],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// act
|
|
||||||
await expect(
|
await expect(
|
||||||
useCase.execute({
|
useCase.execute({
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
password: "password",
|
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 { 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 { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.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", () => {
|
describe("Auth - Create session", () => {
|
||||||
let usersRepo: UsersInMemoryRepository;
|
let identityRepo: AuthIdentityInMemoryRepository;
|
||||||
const MockCryptoService = vi.fn(
|
const MockCryptoService = vi.fn(
|
||||||
class implements ICryptoService {
|
class implements ICryptoService {
|
||||||
randomId = vi.fn().mockReturnValue("2");
|
randomId = vi.fn().mockReturnValue("2");
|
||||||
@@ -41,9 +42,16 @@ describe("Auth - Login user", () => {
|
|||||||
const logger = new MockLogger();
|
const logger = new MockLogger();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
usersRepo = new UsersInMemoryRepository();
|
identityRepo = new AuthIdentityInMemoryRepository();
|
||||||
usersRepo.save(
|
identityRepo.save(
|
||||||
new UserEntity("1", "test@example.com", "password", true, new Date()),
|
new AuthIdentityEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
true,
|
||||||
|
new Date(),
|
||||||
|
[],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,11 +59,11 @@ describe("Auth - Login user", () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should login a user", async () => {
|
test("should create a session", async () => {
|
||||||
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
const findOneSpy = vi.spyOn(identityRepo, "findOne");
|
||||||
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
const useCase = new LoginUserUseCase(
|
const useCase = new CreateSessionUseCase(
|
||||||
usersRepo,
|
identityRepo,
|
||||||
cryptoService,
|
cryptoService,
|
||||||
tokenService,
|
tokenService,
|
||||||
logger,
|
logger,
|
||||||
@@ -72,45 +80,41 @@ describe("Auth - Login user", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not login a user if the user is not found", async () => {
|
test("should create a session if the identity is not found", async () => {
|
||||||
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
const findOneSpy = vi.spyOn(identityRepo, "findOne");
|
||||||
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
const useCase = new LoginUserUseCase(
|
const useCase = new CreateSessionUseCase(
|
||||||
usersRepo,
|
identityRepo,
|
||||||
cryptoService,
|
cryptoService,
|
||||||
tokenService,
|
tokenService,
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
const result = await useCase.execute({
|
await expect(
|
||||||
email: "test2@example.com",
|
useCase.execute({
|
||||||
password: "password",
|
email: "test2@example.com",
|
||||||
});
|
password: "password",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(InvalidCredentials);
|
||||||
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(result).toEqual({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not login a user if the password is invalid", async () => {
|
test("should create a session if the password is invalid", async () => {
|
||||||
const findOneSpy = vi.spyOn(usersRepo, "findOne");
|
const findOneSpy = vi.spyOn(identityRepo, "findOne");
|
||||||
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
const generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
const useCase = new LoginUserUseCase(
|
const useCase = new CreateSessionUseCase(
|
||||||
usersRepo,
|
identityRepo,
|
||||||
cryptoService,
|
cryptoService,
|
||||||
tokenService,
|
tokenService,
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
const result = await useCase.execute({
|
await expect(
|
||||||
email: "test@example.com",
|
useCase.execute({
|
||||||
password: "password2",
|
email: "test@example.com",
|
||||||
});
|
password: "password2",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(InvalidCredentials);
|
||||||
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(result).toEqual({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { inject, injectable, optional } from "inversify";
|
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 { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||||
import type {
|
import type {
|
||||||
ILogger,
|
ILogger,
|
||||||
@@ -9,23 +7,26 @@ import type {
|
|||||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
import type { IUseCase } from "@/shared/core/IUseCase.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;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
export type LoginUserResult = {
|
export type CreateSessionResult = {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class LoginUserUseCase
|
export class CreateSessionUseCase
|
||||||
implements IUseCase<LoginUserDTO, LoginUserResult>
|
implements IUseCase<CreateSessionDTO, CreateSessionResult>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
@inject(UsersDomain.IUserRepository)
|
@inject(AuthDomain.IAuthIdentityRepository)
|
||||||
private readonly userRepository: IUsersRepository,
|
private readonly authIdentityRepository: IAuthIdentityRepository,
|
||||||
@inject(SharedDomain.ICryptoService)
|
@inject(SharedDomain.ICryptoService)
|
||||||
private readonly cryptoService: ICryptoService,
|
private readonly cryptoService: ICryptoService,
|
||||||
@inject(SharedDomain.ITokenService)
|
@inject(SharedDomain.ITokenService)
|
||||||
@@ -37,15 +38,17 @@ export class LoginUserUseCase
|
|||||||
private readonly requestContext?: IRequestContext,
|
private readonly requestContext?: IRequestContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(dto: LoginUserDTO): Promise<LoginUserResult> {
|
async execute(dto: CreateSessionDTO): Promise<CreateSessionResult> {
|
||||||
const user = await this.userRepository.findOne({ email: dto.email });
|
const user = await this.authIdentityRepository.findOne({
|
||||||
|
email: dto.email,
|
||||||
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
message: "Invalid credentials",
|
message: "Invalid credentials",
|
||||||
module: "LoginUserUseCase",
|
module: "CreateSessionUseCase",
|
||||||
context: this.requestContext,
|
context: this.requestContext,
|
||||||
});
|
});
|
||||||
return { token: null, refreshToken: null };
|
throw new InvalidCredentials();
|
||||||
}
|
}
|
||||||
const isPasswordValid = await this.cryptoService.comparePassword(
|
const isPasswordValid = await this.cryptoService.comparePassword(
|
||||||
dto.password,
|
dto.password,
|
||||||
@@ -54,16 +57,16 @@ export class LoginUserUseCase
|
|||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
message: "Invalid credentials",
|
message: "Invalid credentials",
|
||||||
module: "LoginUserUseCase",
|
module: "CreateSessionUseCase",
|
||||||
context: this.requestContext,
|
context: this.requestContext,
|
||||||
});
|
});
|
||||||
return { token: null, refreshToken: null };
|
throw new InvalidCredentials();
|
||||||
}
|
}
|
||||||
const token = this.tokenService.generateToken(user);
|
const token = this.tokenService.generateToken(user);
|
||||||
const refreshToken = this.tokenService.generateRefreshToken(user);
|
const refreshToken = this.tokenService.generateRefreshToken(user);
|
||||||
this.logger.info({
|
this.logger.info({
|
||||||
message: "User logged in",
|
message: "Logged in.",
|
||||||
module: "LoginUserUseCase",
|
module: "CreateSessionUseCase",
|
||||||
context: this.requestContext,
|
context: this.requestContext,
|
||||||
});
|
});
|
||||||
return { token, refreshToken };
|
return { token, refreshToken };
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
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 { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.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";
|
import { RefreshSessionUseCase } from "./refresh-session.js";
|
||||||
|
|
||||||
describe("Auth - Refresh session", () => {
|
describe("Auth - Refresh session", () => {
|
||||||
let usersRepo: UsersInMemoryRepository;
|
let identityRepo: AuthIdentityInMemoryRepository;
|
||||||
const MockTokenService = vi.fn(
|
const MockTokenService = vi.fn(
|
||||||
class implements ITokenService {
|
class implements ITokenService {
|
||||||
generateToken = vi.fn().mockReturnValue("token");
|
generateToken = vi.fn().mockReturnValue("token");
|
||||||
generateRefreshToken = vi.fn().mockReturnValue("refresh-token");
|
generateRefreshToken = vi.fn().mockReturnValue("refresh-token");
|
||||||
getSession = vi.fn().mockReturnValue({
|
getSession = vi.fn().mockReturnValue({
|
||||||
userId: "1",
|
identityId: "1",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
loginDate: new Date(),
|
loginDate: new Date(),
|
||||||
});
|
});
|
||||||
validateRefreshToken = vi.fn((refreshToken) => {
|
validateRefreshToken = vi.fn((refreshToken) => {
|
||||||
if (refreshToken === "refresh-token") {
|
if (refreshToken === "refresh-token") {
|
||||||
return { userId: "1" };
|
return { identityId: "1" };
|
||||||
}
|
}
|
||||||
if (refreshToken === "non-existant-user") {
|
if (refreshToken === "non-existant-user") {
|
||||||
return { userId: "2" };
|
return { identityId: "2" };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -40,9 +41,16 @@ describe("Auth - Refresh session", () => {
|
|||||||
const logger = new MockLogger();
|
const logger = new MockLogger();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
usersRepo = new UsersInMemoryRepository();
|
identityRepo = new AuthIdentityInMemoryRepository();
|
||||||
usersRepo.save(
|
identityRepo.save(
|
||||||
new UserEntity("1", "test@example.com", "password", true, new Date()),
|
new AuthIdentityEntity(
|
||||||
|
"1",
|
||||||
|
"test@example.com",
|
||||||
|
"password",
|
||||||
|
true,
|
||||||
|
new Date(),
|
||||||
|
[],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,9 +59,13 @@ describe("Auth - Refresh session", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should refresh a session", async () => {
|
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 generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
|
const useCase = new RefreshSessionUseCase(
|
||||||
|
identityRepo,
|
||||||
|
tokenService,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
refreshToken: "refresh-token",
|
refreshToken: "refresh-token",
|
||||||
});
|
});
|
||||||
@@ -66,32 +78,36 @@ describe("Auth - Refresh session", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should not refresh when refresh token is invalid", async () => {
|
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 generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
|
const useCase = new RefreshSessionUseCase(
|
||||||
const result = await useCase.execute({
|
identityRepo,
|
||||||
refreshToken: "invalid-refresh-token",
|
tokenService,
|
||||||
});
|
logger,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
useCase.execute({
|
||||||
|
refreshToken: "invalid-refresh-token",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(InvalidSession);
|
||||||
expect(findOneSpy).toHaveBeenCalledTimes(0);
|
expect(findOneSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(result).toEqual({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not refresh when user id does not exist", async () => {
|
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 generateTokenSpy = vi.spyOn(tokenService, "generateToken");
|
||||||
const useCase = new RefreshSessionUseCase(usersRepo, tokenService, logger);
|
const useCase = new RefreshSessionUseCase(
|
||||||
const result = await useCase.execute({
|
identityRepo,
|
||||||
refreshToken: "non-existant-user",
|
tokenService,
|
||||||
});
|
logger,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
useCase.execute({
|
||||||
|
refreshToken: "non-existant-user",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(InvalidSession);
|
||||||
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
expect(generateTokenSpy).toHaveBeenCalledTimes(0);
|
||||||
expect(result).toEqual({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { inject, injectable, optional } from "inversify";
|
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 {
|
import type {
|
||||||
ILogger,
|
ILogger,
|
||||||
IRequestContext,
|
IRequestContext,
|
||||||
@@ -8,6 +6,9 @@ import type {
|
|||||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
import type { IUseCase } from "@/shared/core/IUseCase.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 = {
|
export type RefreshSessionDTO = {
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -22,8 +23,8 @@ export class RefreshSessionUseCase
|
|||||||
implements IUseCase<RefreshSessionDTO, RefreshSessionResult>
|
implements IUseCase<RefreshSessionDTO, RefreshSessionResult>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
@inject(UsersDomain.IUserRepository)
|
@inject(AuthDomain.IAuthIdentityRepository)
|
||||||
private readonly userRepository: IUsersRepository,
|
private readonly userRepository: IAuthIdentityRepository,
|
||||||
@inject(SharedDomain.ITokenService)
|
@inject(SharedDomain.ITokenService)
|
||||||
private readonly tokenService: ITokenService,
|
private readonly tokenService: ITokenService,
|
||||||
@inject(SharedDomain.ILogger)
|
@inject(SharedDomain.ILogger)
|
||||||
@@ -37,22 +38,24 @@ export class RefreshSessionUseCase
|
|||||||
const refreshData = this.tokenService.validateRefreshToken(
|
const refreshData = this.tokenService.validateRefreshToken(
|
||||||
dto.refreshToken,
|
dto.refreshToken,
|
||||||
);
|
);
|
||||||
if (!refreshData?.userId) {
|
if (!refreshData?.identityId) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
message: "Invalid refresh token",
|
message: "Invalid refresh token",
|
||||||
module: "RefreshSessionUseCase",
|
module: "RefreshSessionUseCase",
|
||||||
context: this.requestContext,
|
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) {
|
if (!user) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
message: "Invalid refresh token",
|
message: "Invalid refresh token",
|
||||||
module: "RefreshSessionUseCase",
|
module: "RefreshSessionUseCase",
|
||||||
context: this.requestContext,
|
context: this.requestContext,
|
||||||
});
|
});
|
||||||
return { token: null, refreshToken: null };
|
throw new InvalidSession();
|
||||||
}
|
}
|
||||||
const token = this.tokenService.generateToken(user);
|
const token = this.tokenService.generateToken(user);
|
||||||
const refreshToken = this.tokenService.generateRefreshToken(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 { InvalidEmailFormat } from "./errors/InvalidEmailFormat.js";
|
||||||
|
import { InvalidMagicToken } from "./errors/InvalidMagicToken.js";
|
||||||
import { InvalidPassword } from "./errors/InvalidPassword.js";
|
import { InvalidPassword } from "./errors/InvalidPassword.js";
|
||||||
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
|
import { NewPasswordMustBeDifferent } from "./errors/NewPasswordMustBeDifferent.js";
|
||||||
|
|
||||||
export class UserEntity {
|
export class AuthIdentityEntity {
|
||||||
constructor(
|
constructor(
|
||||||
public id: string,
|
public id: string,
|
||||||
public email: string,
|
public email: string,
|
||||||
public password: string,
|
public password: string,
|
||||||
public isVerified: boolean,
|
public isVerified: boolean,
|
||||||
public createdAt: Date,
|
public createdAt: Date,
|
||||||
|
public verifications: AuthVerificationEntity[],
|
||||||
) {
|
) {
|
||||||
if (!email.includes("@")) {
|
if (!email.includes("@")) {
|
||||||
throw new InvalidEmailFormat();
|
throw new InvalidEmailFormat();
|
||||||
@@ -18,14 +22,28 @@ export class UserEntity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the age of the account in seconds
|
|
||||||
* @returns account age in seconds
|
|
||||||
*/
|
|
||||||
getAccountAge() {
|
getAccountAge() {
|
||||||
return (Date.now() - this.createdAt.getTime()) / 1000;
|
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) {
|
changeEmail(newEmail: string) {
|
||||||
if (!newEmail.includes("@")) {
|
if (!newEmail.includes("@")) {
|
||||||
throw new InvalidEmailFormat();
|
throw new InvalidEmailFormat();
|
||||||
@@ -42,8 +60,4 @@ export class UserEntity {
|
|||||||
}
|
}
|
||||||
this.password = newHashedPassword;
|
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 { ContainerModule } from "inversify";
|
||||||
import { LoginUserUseCase } from "../../use-cases/login-user.js";
|
import { CreateIdentityUseCase } from "../../application/use-cases/create-identity.js";
|
||||||
import { RefreshSessionUseCase } from "../../use-cases/refresh-session.js";
|
import { CreateSessionUseCase } from "../../application/use-cases/create-session.js";
|
||||||
import { UserSignupUseCase } from "../../use-cases/user-signup.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 }) => {
|
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(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 { appContainer } from "@/shared/infrastructure/di/Container.js";
|
||||||
import { requireAuth } from "@/shared/infrastructure/http/middlewares/requireAuth.js";
|
import { requireAuth } from "@/shared/infrastructure/http/middlewares/requireAuth.js";
|
||||||
import { respondWithGenericError } from "@/shared/infrastructure/http/responses/respondWithGenericError.js";
|
import { respondWithGenericError } from "@/shared/infrastructure/http/responses/respondWithGenericError.js";
|
||||||
import { LoginUserUseCase } from "../../use-cases/login-user.js";
|
import { CreateIdentityUseCase } from "../../application/use-cases/create-identity.js";
|
||||||
import { RefreshSessionUseCase } from "../../use-cases/refresh-session.js";
|
import { CreateSessionUseCase } from "../../application/use-cases/create-session.js";
|
||||||
import { UserSignupUseCase } from "../../use-cases/user-signup.js";
|
import { RefreshSessionUseCase } from "../../application/use-cases/refresh-session.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const LoginRequestSchema = z.object({
|
|||||||
* post:
|
* post:
|
||||||
* tags:
|
* tags:
|
||||||
* - Auth
|
* - Auth
|
||||||
* summary: Login a user
|
* summary: Login via an identity and create a session
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -53,7 +53,7 @@ router.post("/login", async (req, res) => {
|
|||||||
SharedDomain.IConfigService,
|
SharedDomain.IConfigService,
|
||||||
);
|
);
|
||||||
const { email, password } = LoginRequestSchema.parse(req.body);
|
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 });
|
const { token, refreshToken } = await useCase.execute({ email, password });
|
||||||
|
|
||||||
if (token && refreshToken) {
|
if (token && refreshToken) {
|
||||||
@@ -116,7 +116,7 @@ const RegisterRequestSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
router.post("/register", async (req, res) => {
|
router.post("/register", async (req, res) => {
|
||||||
const { email, password } = RegisterRequestSchema.parse(req.body);
|
const { email, password } = RegisterRequestSchema.parse(req.body);
|
||||||
const useCase = appContainer.get(UserSignupUseCase);
|
const useCase = appContainer.get(CreateIdentityUseCase);
|
||||||
await useCase.execute({ email, password });
|
await useCase.execute({ email, password });
|
||||||
res.status(200).send();
|
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
|
* type: string
|
||||||
*/
|
*/
|
||||||
router.get("/", (req, res) => {
|
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.");
|
return res.send("Hello world! You are not logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send(`Hello ${currentUser.email}`);
|
res.send(`Hello ${currentIdentity.email}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
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 {
|
export interface ISession {
|
||||||
userId: string;
|
identityId: string;
|
||||||
email: string;
|
email: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
loginDate: Date;
|
loginDate: Date;
|
||||||
}
|
}
|
||||||
export interface IRefreshData {
|
export interface IRefreshData {
|
||||||
userId: string;
|
identityId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITokenService {
|
export interface ITokenService {
|
||||||
generateToken(user: UserEntity): string;
|
generateToken(
|
||||||
generateRefreshToken(user: UserEntity): string;
|
identity: AuthIdentityDto,
|
||||||
|
additionalClaims?: Record<string, string | boolean | number>,
|
||||||
|
): string;
|
||||||
|
generateRefreshToken(identity: AuthIdentityDto): string;
|
||||||
getSession(token: string): ISession | null;
|
getSession(token: string): ISession | null;
|
||||||
validateRefreshToken(refreshToken: string): IRefreshData | 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> = {
|
export type FilterRange<T> = {
|
||||||
from?: T;
|
from?: T;
|
||||||
to?: T;
|
to?: T;
|
||||||
@@ -15,7 +17,7 @@ type FilterValue<T> = T extends string
|
|||||||
: never; // Exclude types that are not string, number, Date, or boolean
|
: never; // Exclude types that are not string, number, Date, or boolean
|
||||||
|
|
||||||
export type FilterCriteria<T> = {
|
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 = {
|
export type PaginationOptions = {
|
||||||
@@ -23,7 +25,7 @@ export type PaginationOptions = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WithPagination<T> = {
|
export type PaginationResult<T> = {
|
||||||
data: T[];
|
data: T[];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
@@ -34,7 +36,7 @@ export interface IBaseRepository<T> {
|
|||||||
findAll(
|
findAll(
|
||||||
criteria?: FilterCriteria<T>,
|
criteria?: FilterCriteria<T>,
|
||||||
paginationOptions?: PaginationOptions,
|
paginationOptions?: PaginationOptions,
|
||||||
): Promise<WithPagination<T>>;
|
): Promise<PaginationResult<T>>;
|
||||||
save(entity: T): Promise<T | null>;
|
save(entity: T): Promise<T | null>;
|
||||||
generateId(): string;
|
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 { inject, injectable } from "inversify";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import z from "zod";
|
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 { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -34,7 +34,7 @@ export class JwtService implements ITokenService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
generateToken(
|
generateToken(
|
||||||
user: UserEntity,
|
identity: AuthIdentityDto,
|
||||||
additionalClaims?: Record<string, string | boolean | number>,
|
additionalClaims?: Record<string, string | boolean | number>,
|
||||||
): string {
|
): string {
|
||||||
const duration = Number.parseInt(
|
const duration = Number.parseInt(
|
||||||
@@ -45,9 +45,9 @@ export class JwtService implements ITokenService {
|
|||||||
const claims = {
|
const claims = {
|
||||||
iat: Math.ceil(Date.now() / 1000),
|
iat: Math.ceil(Date.now() / 1000),
|
||||||
exp: Math.ceil(Date.now() / 1000) + duration,
|
exp: Math.ceil(Date.now() / 1000) + duration,
|
||||||
sub: user.id,
|
sub: identity.id,
|
||||||
email: user.email,
|
email: identity.email,
|
||||||
isVerified: user.isVerified,
|
isVerified: identity.isVerified,
|
||||||
...additionalClaims,
|
...additionalClaims,
|
||||||
};
|
};
|
||||||
const jwtClaims = JWTSessionSchema.parse(claims);
|
const jwtClaims = JWTSessionSchema.parse(claims);
|
||||||
@@ -57,7 +57,7 @@ export class JwtService implements ITokenService {
|
|||||||
});
|
});
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
generateRefreshToken(user: UserEntity): string {
|
generateRefreshToken(identity: AuthIdentityDto): string {
|
||||||
const duration = Number.parseInt(
|
const duration = Number.parseInt(
|
||||||
this.configService.get("JWT_REFRESH_DURATION"),
|
this.configService.get("JWT_REFRESH_DURATION"),
|
||||||
10,
|
10,
|
||||||
@@ -66,7 +66,7 @@ export class JwtService implements ITokenService {
|
|||||||
const claims = {
|
const claims = {
|
||||||
iat: Math.ceil(Date.now() / 1000),
|
iat: Math.ceil(Date.now() / 1000),
|
||||||
exp: Math.ceil(Date.now() / 1000) + duration,
|
exp: Math.ceil(Date.now() / 1000) + duration,
|
||||||
sub: user.id,
|
sub: identity.id,
|
||||||
};
|
};
|
||||||
const jwtClaims = JWTRefreshSchema.parse(claims);
|
const jwtClaims = JWTRefreshSchema.parse(claims);
|
||||||
const token = jwt.sign(jwtClaims, secret, {
|
const token = jwt.sign(jwtClaims, secret, {
|
||||||
@@ -89,7 +89,7 @@ export class JwtService implements ITokenService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const session = {
|
const session = {
|
||||||
userId: decodedToken.sub,
|
identityId: decodedToken.sub,
|
||||||
email: decodedToken.email,
|
email: decodedToken.email,
|
||||||
isVerified: decodedToken.isVerified,
|
isVerified: decodedToken.isVerified,
|
||||||
loginDate: new Date(decodedToken.iat * 1000),
|
loginDate: new Date(decodedToken.iat * 1000),
|
||||||
@@ -123,7 +123,7 @@ export class JwtService implements ITokenService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const refreshData = {
|
const refreshData = {
|
||||||
userId: decodedToken.sub,
|
identityId: decodedToken.sub,
|
||||||
};
|
};
|
||||||
return refreshData;
|
return refreshData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Container } from "inversify";
|
import { Container } from "inversify";
|
||||||
import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js";
|
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";
|
import { SharedDIModule } from "./shared.di.js";
|
||||||
|
|
||||||
const appContainer = new Container();
|
const appContainer = new Container();
|
||||||
|
|
||||||
appContainer.load(SharedDIModule);
|
appContainer.load(SharedDIModule);
|
||||||
appContainer.load(AuthDIModule);
|
appContainer.load(AuthDIModule);
|
||||||
appContainer.load(UsersDIModule);
|
appContainer.load(UserDIModule);
|
||||||
|
|
||||||
export { appContainer };
|
export { appContainer };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
|
import type { IAuthQueryService } from "@/modules/auth/application/query-service.js";
|
||||||
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
|
import { AuthDomain } from "@/modules/auth/domain/auth.symbols.js";
|
||||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
import { appContainer } from "../../di/Container.js";
|
import { appContainer } from "../../di/Container.js";
|
||||||
@@ -13,8 +13,8 @@ export const attachSession = async (
|
|||||||
const tokenService = appContainer.get<ITokenService>(
|
const tokenService = appContainer.get<ITokenService>(
|
||||||
SharedDomain.ITokenService,
|
SharedDomain.ITokenService,
|
||||||
);
|
);
|
||||||
const userRepo = appContainer.get<IUsersRepository>(
|
const authQueryService = appContainer.get<IAuthQueryService>(
|
||||||
UsersDomain.IUserRepository,
|
AuthDomain.IAuthIdentityQueryService,
|
||||||
);
|
);
|
||||||
const token = req.cookies.token;
|
const token = req.cookies.token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -26,12 +26,12 @@ export const attachSession = async (
|
|||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentUser = await userRepo.findOne({ id: session.userId });
|
const currentIdentity = await authQueryService.findById(session.identityId);
|
||||||
if (!currentUser) {
|
if (!currentIdentity) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req.session = session;
|
req.session = session;
|
||||||
req.currentUser = currentUser;
|
req.currentIdentity = currentIdentity;
|
||||||
next();
|
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";
|
import type { ISession } from "@/shared/application/ports/ITokenService.js";
|
||||||
|
|
||||||
declare module "express-serve-static-core" {
|
declare module "express-serve-static-core" {
|
||||||
interface Request {
|
interface Request {
|
||||||
session?: ISession;
|
session?: ISession;
|
||||||
currentUser?: UserEntity;
|
currentIdentity?: AuthIdentityDto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ErrorLogMessage,
|
||||||
ILogger,
|
ILogger,
|
||||||
LogMessage,
|
LogMessage,
|
||||||
} from "@/shared/application/ports/ILogger.js";
|
} from "@/shared/application/ports/ILogger.js";
|
||||||
@@ -20,8 +21,9 @@ export class ConsoleLogger implements ILogger {
|
|||||||
console.log(messageBuilder(message));
|
console.log(messageBuilder(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: LogMessage): void {
|
error(message: ErrorLogMessage): void {
|
||||||
console.error(messageBuilder(message));
|
console.error(messageBuilder(message));
|
||||||
|
console.error(message.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: LogMessage): void {
|
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";
|
import { InMemoryRepository } from "./InMemoryRepository.js";
|
||||||
|
|
||||||
type TestEntity = {
|
type TestEntity = {
|
||||||
@@ -18,385 +18,479 @@ describe("InMemoryRepository (Generic) - Comprehensive Tests", () => {
|
|||||||
repo = new TestRepository();
|
repo = new TestRepository();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Persistence Operations", () => {
|
test("Persistence Operations - should save and find an entity by ID", async () => {
|
||||||
it("should save and find an entity by ID", async () => {
|
const id = repo.generateId();
|
||||||
const id = repo.generateId();
|
const entity: TestEntity = {
|
||||||
const entity: TestEntity = {
|
id,
|
||||||
id,
|
name: "Test",
|
||||||
name: "Test",
|
age: 25,
|
||||||
age: 25,
|
isActive: true,
|
||||||
isActive: true,
|
createdAt: new Date(),
|
||||||
createdAt: new Date(),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
const found = await repo.findById(id);
|
const found = await repo.findById(id);
|
||||||
|
|
||||||
expect(found).toEqual(entity);
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Query Operations", () => {
|
test("Persistence Operations - should update an existing entity with the same ID", async () => {
|
||||||
it("should find one entity by filter criteria", async () => {
|
const id = repo.generateId();
|
||||||
await repo.save({
|
const entity: TestEntity = {
|
||||||
id: "1",
|
id,
|
||||||
name: "Unique",
|
name: "Old Name",
|
||||||
age: 25,
|
age: 25,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
};
|
||||||
await repo.save({
|
|
||||||
id: "2",
|
|
||||||
name: "Other",
|
|
||||||
age: 30,
|
|
||||||
isActive: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const found = await repo.findOne({ name: "Unique" });
|
await repo.save(entity);
|
||||||
expect(found).toBeDefined();
|
|
||||||
expect(found?.id).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null if findOne matches nothing", async () => {
|
// Modify and save again
|
||||||
const found = await repo.findOne({ name: "NonExistent" });
|
entity.name = "New Name";
|
||||||
expect(found).toBeNull();
|
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", () => {
|
test("Persistence Operations - should return null if finding by non-existent ID", async () => {
|
||||||
it("should filter by string (exact match)", async () => {
|
const found = await repo.findById("non-existent-id");
|
||||||
await repo.save({
|
expect(found).toBeNull();
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Range Filtering - Number", () => {
|
test("Query Operations - should find one entity by filter criteria", async () => {
|
||||||
beforeEach(async () => {
|
await repo.save({
|
||||||
await repo.save({
|
id: "1",
|
||||||
id: "1",
|
name: "Unique",
|
||||||
name: "Kid",
|
age: 25,
|
||||||
age: 10,
|
isActive: true,
|
||||||
isActive: true,
|
createdAt: new Date(),
|
||||||
createdAt: new Date(),
|
});
|
||||||
});
|
await repo.save({
|
||||||
await repo.save({
|
id: "2",
|
||||||
id: "2",
|
name: "Other",
|
||||||
name: "Teen",
|
age: 30,
|
||||||
age: 15,
|
isActive: false,
|
||||||
isActive: true,
|
createdAt: new Date(),
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
await repo.save({
|
|
||||||
id: "3",
|
|
||||||
name: "Adult",
|
|
||||||
age: 25,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter by number range (from only - tail case)", async () => {
|
const found = await repo.findOne({ name: "Unique" });
|
||||||
// age >= 15
|
expect(found).toBeDefined();
|
||||||
const result = await repo.findAll({ age: { from: 15 } });
|
expect(found?.id).toBe("1");
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 d1 = new Date("2023-01-01T10:00:00Z");
|
||||||
const d2 = new Date("2023-01-02T10:00:00Z");
|
const d2 = new Date("2023-01-02T10:00:00Z");
|
||||||
const d3 = new Date("2023-01-03T10:00:00Z");
|
const d3 = new Date("2023-01-03T10:00:00Z");
|
||||||
|
|
||||||
beforeEach(async () => {
|
await repo.save({
|
||||||
await repo.save({
|
id: "1",
|
||||||
id: "1",
|
name: "First",
|
||||||
name: "First",
|
age: 20,
|
||||||
age: 20,
|
isActive: true,
|
||||||
isActive: true,
|
createdAt: d1,
|
||||||
createdAt: d1,
|
});
|
||||||
});
|
await repo.save({
|
||||||
await repo.save({
|
id: "2",
|
||||||
id: "2",
|
name: "Second",
|
||||||
name: "Second",
|
age: 20,
|
||||||
age: 20,
|
isActive: true,
|
||||||
isActive: true,
|
createdAt: d2,
|
||||||
createdAt: d2,
|
});
|
||||||
});
|
await repo.save({
|
||||||
await repo.save({
|
id: "3",
|
||||||
id: "3",
|
name: "Third",
|
||||||
name: "Third",
|
age: 20,
|
||||||
age: 20,
|
isActive: true,
|
||||||
isActive: true,
|
createdAt: d3,
|
||||||
createdAt: d3,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter by date range (from only - tail case)", async () => {
|
// date >= d2
|
||||||
// date >= d2
|
const result = await repo.findAll({ createdAt: { from: d2 } });
|
||||||
const result = await repo.findAll({ createdAt: { from: d2 } });
|
expect(result.data).toHaveLength(2); // Second, Third
|
||||||
expect(result.data).toHaveLength(2); // Second, Third
|
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
||||||
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
d2.getTime(),
|
||||||
d2.getTime(),
|
d3.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
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Pagination with Filtering", () => {
|
test("Range Filtering - Date - should filter by date range (to only - head case)", async () => {
|
||||||
beforeEach(async () => {
|
const d1 = new Date("2023-01-01T10:00:00Z");
|
||||||
// Create 10 items
|
const d2 = new Date("2023-01-02T10:00:00Z");
|
||||||
for (let i = 1; i <= 10; i++) {
|
const d3 = new Date("2023-01-03T10:00:00Z");
|
||||||
await repo.save({
|
|
||||||
id: i.toString(),
|
await repo.save({
|
||||||
name: i % 2 === 0 ? "Even" : "Odd",
|
id: "1",
|
||||||
age: i * 10, // 10, 20, ..., 100
|
name: "First",
|
||||||
isActive: true,
|
age: 20,
|
||||||
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
|
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 () => {
|
// date <= d2
|
||||||
// Filter: name = "Even" (5 items: 2, 4, 6, 8, 10)
|
const result = await repo.findAll({ createdAt: { to: d2 } });
|
||||||
// Page 1: limit 2 -> [2, 4]
|
expect(result.data).toHaveLength(2); // First, Second
|
||||||
const page1 = await repo.findAll(
|
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
||||||
{ name: "Even" },
|
d1.getTime(),
|
||||||
{ offset: 0, limit: 2 },
|
d2.getTime(),
|
||||||
);
|
]);
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Utility Operations", () => {
|
test("Range Filtering - Date - should filter by date range (from and to - middle case)", async () => {
|
||||||
it("should generate unique IDs", () => {
|
const d1 = new Date("2023-01-01T10:00:00Z");
|
||||||
const ids = new Set<string>();
|
const d2 = new Date("2023-01-02T10:00:00Z");
|
||||||
for (let i = 0; i < 1000; i++) {
|
const d3 = new Date("2023-01-03T10:00:00Z");
|
||||||
ids.add(repo.generateId());
|
|
||||||
}
|
await repo.save({
|
||||||
expect(ids.size).toBe(1000);
|
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,
|
FilterRange,
|
||||||
IBaseRepository,
|
IBaseRepository,
|
||||||
PaginationOptions,
|
PaginationOptions,
|
||||||
WithPagination,
|
PaginationResult,
|
||||||
} from "@/shared/core/IBaseRepository.js";
|
} from "@/shared/core/IBaseRepository.js";
|
||||||
|
|
||||||
export class InMemoryRepository<T extends { id: string }>
|
export class InMemoryRepository<T extends { id: string }>
|
||||||
implements IBaseRepository<T>
|
implements IBaseRepository<T>
|
||||||
{
|
{
|
||||||
protected items: T[] = [];
|
public items: T[] = [];
|
||||||
|
|
||||||
async save(entity: T): Promise<T | null> {
|
async save(entity: T): Promise<T | null> {
|
||||||
const index = this.items.findIndex((item) => item.id === entity.id);
|
const index = this.items.findIndex((item) => item.id === entity.id);
|
||||||
@@ -34,7 +34,7 @@ export class InMemoryRepository<T extends { id: string }>
|
|||||||
async findAll(
|
async findAll(
|
||||||
criteria?: FilterCriteria<T>,
|
criteria?: FilterCriteria<T>,
|
||||||
paginationOptions?: PaginationOptions,
|
paginationOptions?: PaginationOptions,
|
||||||
): Promise<WithPagination<T>> {
|
): Promise<PaginationResult<T>> {
|
||||||
let filtered = this.items;
|
let filtered = this.items;
|
||||||
|
|
||||||
if (criteria) {
|
if (criteria) {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
import { inject, injectable } from "inversify";
|
import { inject, injectable } from "inversify";
|
||||||
import { uuidv7 } from "uuidv7";
|
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 type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||||
|
|
||||||
export type PrismaClient = PrismaClientLib;
|
export { PrismaClient };
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class PrismaClientWrapper {
|
export class PrismaClientWrapper {
|
||||||
private readonly client: PrismaClientLib;
|
private readonly client: PrismaClient;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@inject(SharedDomain.IConfigService)
|
@inject(SharedDomain.IConfigService)
|
||||||
@@ -21,12 +21,12 @@ export class PrismaClientWrapper {
|
|||||||
connectionString,
|
connectionString,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client = new PrismaClientLib({
|
this.client = new PrismaClient({
|
||||||
adapter,
|
adapter,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient(): PrismaClientLib {
|
getClient(): PrismaClient {
|
||||||
return this.client;
|
return this.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const config = {
|
|||||||
definition: {
|
definition: {
|
||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
info: {
|
info: {
|
||||||
title: "Express-Starter",
|
title: "Cedar CMS",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,6 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"prisma.config.ts",
|
"prisma.config.ts",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"dist"
|
"dist",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user