Initial commit
This commit is contained in:
13
src/shared/infrastructure/config/EnvConfigService.ts
Normal file
13
src/shared/infrastructure/config/EnvConfigService.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||
|
||||
export class EnvConfigService implements IConfigService {
|
||||
get(key: string): string {
|
||||
return process.env[key] ?? "";
|
||||
}
|
||||
isDevelopment(): boolean {
|
||||
return (
|
||||
process.env.ENVIRONMENT !== "production" ||
|
||||
process.env.NODE_ENV === "development"
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/shared/infrastructure/crypto/BcryptService.ts
Normal file
15
src/shared/infrastructure/crypto/BcryptService.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||
|
||||
export class BcryptService implements ICryptoService {
|
||||
hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
randomId(): string {
|
||||
return uuidv7();
|
||||
}
|
||||
}
|
||||
142
src/shared/infrastructure/crypto/JwtService.ts
Normal file
142
src/shared/infrastructure/crypto/JwtService.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import z from "zod";
|
||||
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||
import type {
|
||||
IRefreshData,
|
||||
ISession,
|
||||
ITokenService,
|
||||
} from "@/shared/application/ports/ITokenService.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { appContainer } from "../di/Container.js";
|
||||
|
||||
const JWTSessionSchema = z.object({
|
||||
sub: z.string(),
|
||||
email: z.string(),
|
||||
isVerified: z.boolean(),
|
||||
iat: z.number(),
|
||||
exp: z.number(),
|
||||
});
|
||||
|
||||
const JWTRefreshSchema = z.object({
|
||||
sub: z.string(),
|
||||
iat: z.number(),
|
||||
exp: z.number(),
|
||||
});
|
||||
|
||||
@injectable()
|
||||
export class JwtService implements ITokenService {
|
||||
constructor(
|
||||
@inject(SharedDomain.IConfigService)
|
||||
private readonly configService: IConfigService,
|
||||
) {}
|
||||
|
||||
generateToken(
|
||||
user: UserEntity,
|
||||
additionalClaims?: Record<string, string | boolean | number>,
|
||||
): string {
|
||||
const duration = Number.parseInt(
|
||||
this.configService.get("JWT_DURATION"),
|
||||
10,
|
||||
);
|
||||
const secret = this.configService.get("JWT_SECRET");
|
||||
const claims = {
|
||||
iat: Math.ceil(Date.now() / 1000),
|
||||
exp: Math.ceil(Date.now() / 1000) + duration,
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isVerified: user.isVerified,
|
||||
...additionalClaims,
|
||||
};
|
||||
const jwtClaims = JWTSessionSchema.parse(claims);
|
||||
|
||||
const token = jwt.sign(jwtClaims, secret, {
|
||||
algorithm: "HS256",
|
||||
});
|
||||
return token;
|
||||
}
|
||||
generateRefreshToken(user: UserEntity): string {
|
||||
const duration = Number.parseInt(
|
||||
this.configService.get("JWT_REFRESH_DURATION"),
|
||||
10,
|
||||
);
|
||||
const secret = this.configService.get("JWT_REFRESH_SECRET");
|
||||
const claims = {
|
||||
iat: Math.ceil(Date.now() / 1000),
|
||||
exp: Math.ceil(Date.now() / 1000) + duration,
|
||||
sub: user.id,
|
||||
};
|
||||
const jwtClaims = JWTRefreshSchema.parse(claims);
|
||||
const token = jwt.sign(jwtClaims, secret, {
|
||||
algorithm: "HS256",
|
||||
});
|
||||
return token;
|
||||
}
|
||||
getSession(token: string): ISession | null {
|
||||
const secret = this.configService.get("JWT_SECRET");
|
||||
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret);
|
||||
const decodedToken = JWTSessionSchema.parse(decoded);
|
||||
if (decodedToken.exp < Date.now() / 1000) {
|
||||
logger.error({
|
||||
message: "Token expired",
|
||||
module: "JwtService",
|
||||
error: new Error("Token expired"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const session = {
|
||||
userId: decodedToken.sub,
|
||||
email: decodedToken.email,
|
||||
isVerified: decodedToken.isVerified,
|
||||
loginDate: new Date(decodedToken.iat * 1000),
|
||||
};
|
||||
return session;
|
||||
} catch (error) {
|
||||
let errorInstance: Error | undefined;
|
||||
if (error instanceof Error) {
|
||||
errorInstance = error;
|
||||
}
|
||||
logger.error({
|
||||
message: "Invalid token",
|
||||
module: "JwtService",
|
||||
error: errorInstance,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
validateRefreshToken(refreshToken: string): IRefreshData | null {
|
||||
const secret = this.configService.get("JWT_REFRESH_SECRET");
|
||||
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, secret);
|
||||
const decodedToken = JWTRefreshSchema.parse(decoded);
|
||||
if (decodedToken.exp < Date.now() / 1000) {
|
||||
logger.error({
|
||||
message: "Token expired",
|
||||
module: "JwtService",
|
||||
error: new Error("Token expired"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const refreshData = {
|
||||
userId: decodedToken.sub,
|
||||
};
|
||||
return refreshData;
|
||||
} catch (error) {
|
||||
let errorInstance: Error | undefined;
|
||||
if (error instanceof Error) {
|
||||
errorInstance = error;
|
||||
}
|
||||
logger.error({
|
||||
message: "Invalid refresh token",
|
||||
module: "JwtService",
|
||||
error: errorInstance,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/shared/infrastructure/di/Container.ts
Normal file
12
src/shared/infrastructure/di/Container.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Container } from "inversify";
|
||||
import { AuthDIModule } from "@/modules/auth/infrastructure/di/auth.di.js";
|
||||
import { UsersDIModule } from "@/modules/users/infrastructure/di/users.di.js";
|
||||
import { SharedDIModule } from "./shared.di.js";
|
||||
|
||||
const appContainer = new Container();
|
||||
|
||||
appContainer.load(SharedDIModule);
|
||||
appContainer.load(AuthDIModule);
|
||||
appContainer.load(UsersDIModule);
|
||||
|
||||
export { appContainer };
|
||||
14
src/shared/infrastructure/di/shared.di.ts
Normal file
14
src/shared/infrastructure/di/shared.di.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ContainerModule } from "inversify";
|
||||
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||
import type { ICryptoService } from "@/shared/application/ports/ICryptoService.js";
|
||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { EnvConfigService } from "../config/EnvConfigService.js";
|
||||
import { BcryptService } from "../crypto/BcryptService.js";
|
||||
import { JwtService } from "../crypto/JwtService.js";
|
||||
|
||||
export const SharedDIModule = new ContainerModule(({ bind }) => {
|
||||
bind<ICryptoService>(SharedDomain.ICryptoService).to(BcryptService);
|
||||
bind<ITokenService>(SharedDomain.ITokenService).to(JwtService);
|
||||
bind<IConfigService>(SharedDomain.IConfigService).to(EnvConfigService);
|
||||
});
|
||||
44
src/shared/infrastructure/http/error-handlers/catchAll.ts
Normal file
44
src/shared/infrastructure/http/error-handlers/catchAll.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ErrorRequestHandler } from "express";
|
||||
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { ValidationError } from "@/shared/core/errors/ValidationError.js";
|
||||
import { appContainer } from "../../di/Container.js";
|
||||
import { respondWithGenericError } from "../responses/respondWithGenericError.js";
|
||||
import { respondWithValidationError } from "../responses/respondWithValidationError.js";
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const logger = appContainer.get<ILogger>(SharedDomain.ILogger, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
logger?.error({
|
||||
message: err.message,
|
||||
error: err,
|
||||
module: "errorHandler",
|
||||
context: {
|
||||
route: req.originalUrl,
|
||||
method: req.method,
|
||||
userAgent: req.headers["user-agent"] ?? "n/a",
|
||||
ip: req.ip ?? "n/a",
|
||||
},
|
||||
});
|
||||
|
||||
if (err instanceof ValidationError) {
|
||||
respondWithValidationError({
|
||||
res,
|
||||
response: {
|
||||
message: err.message,
|
||||
issues: err.issues,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
respondWithGenericError({
|
||||
res,
|
||||
response: {
|
||||
message: err.message,
|
||||
},
|
||||
statusCode: 500,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { ValidationError } from "@/shared/core/errors/ValidationError.js";
|
||||
import { appContainer } from "../../di/Container.js";
|
||||
|
||||
export const zodValidationHandler = (
|
||||
err: ZodError,
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const logger = appContainer.get<ILogger>(SharedDomain.ILogger, {
|
||||
optional: true,
|
||||
});
|
||||
if (err instanceof ZodError) {
|
||||
const issues = err.issues.map((issue) => ({
|
||||
field: issue.path[0]?.toString() ?? "root",
|
||||
message: issue.message,
|
||||
}));
|
||||
const validationError = new ValidationError("Validation error", issues);
|
||||
logger?.error({
|
||||
message: "ZodError caught!",
|
||||
module: "zodValidationHandler",
|
||||
context: {
|
||||
route: req.originalUrl,
|
||||
method: req.method,
|
||||
userAgent: req.headers["user-agent"] ?? "n/a",
|
||||
ip: req.ip ?? "n/a",
|
||||
},
|
||||
error: validationError,
|
||||
});
|
||||
next(validationError);
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { appContainer } from "@/shared/infrastructure/di/Container.js";
|
||||
|
||||
export const attachRequestContext = (
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
appContainer.bind(SharedDomain.IRequestContext).toConstantValue({
|
||||
route: req.originalUrl,
|
||||
method: req.method,
|
||||
userAgent: req.headers["user-agent"],
|
||||
ip: req.ip,
|
||||
});
|
||||
next();
|
||||
appContainer.unbind(SharedDomain.IRequestContext);
|
||||
};
|
||||
37
src/shared/infrastructure/http/middlewares/attachSession.ts
Normal file
37
src/shared/infrastructure/http/middlewares/attachSession.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { IUsersRepository } from "@/modules/users/domain/users.repo.js";
|
||||
import { UsersDomain } from "@/modules/users/domain/users.symbols.js";
|
||||
import type { ITokenService } from "@/shared/application/ports/ITokenService.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { appContainer } from "../../di/Container.js";
|
||||
|
||||
export const attachSession = async (
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const tokenService = appContainer.get<ITokenService>(
|
||||
SharedDomain.ITokenService,
|
||||
);
|
||||
const userRepo = appContainer.get<IUsersRepository>(
|
||||
UsersDomain.IUserRepository,
|
||||
);
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const session = tokenService.getSession(token);
|
||||
if (!session) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const currentUser = await userRepo.findOne({ id: session.userId });
|
||||
if (!currentUser) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
req.session = session;
|
||||
req.currentUser = currentUser;
|
||||
next();
|
||||
};
|
||||
26
src/shared/infrastructure/http/middlewares/requestLogger.ts
Normal file
26
src/shared/infrastructure/http/middlewares/requestLogger.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { ILogger } from "@/shared/application/ports/ILogger.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
import { appContainer } from "../../di/Container.js";
|
||||
|
||||
export const requestLogger = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
res.on("finish", () => {
|
||||
const logger = appContainer.get<ILogger>(SharedDomain.ILogger);
|
||||
logger.info({
|
||||
message: res.statusCode.toString(),
|
||||
module: "requestLogger",
|
||||
context: {
|
||||
route: req.originalUrl,
|
||||
method: req.method,
|
||||
userAgent: req.headers["user-agent"] ?? "n/a",
|
||||
ip: req.ip ?? "n/a",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
19
src/shared/infrastructure/http/middlewares/requireAuth.ts
Normal file
19
src/shared/infrastructure/http/middlewares/requireAuth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { respondWithGenericError } from "../responses/respondWithGenericError.js";
|
||||
|
||||
export const requireAuth = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (!req.session) {
|
||||
return respondWithGenericError({
|
||||
res,
|
||||
response: {
|
||||
message: "Unauthorized",
|
||||
},
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
14
src/shared/infrastructure/http/middlewares/stripHeaders.ts
Normal file
14
src/shared/infrastructure/http/middlewares/stripHeaders.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
const HEADERS_TO_STRIP = ["x-powered-by"];
|
||||
|
||||
export const stripHeaders = (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
HEADERS_TO_STRIP.forEach((header) => {
|
||||
res.removeHeader(header);
|
||||
});
|
||||
next();
|
||||
};
|
||||
9
src/shared/infrastructure/http/request.ts
Normal file
9
src/shared/infrastructure/http/request.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { UserEntity } from "@/modules/users/domain/users.entity.js";
|
||||
import type { ISession } from "@/shared/application/ports/ITokenService.js";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
session?: ISession;
|
||||
currentUser?: UserEntity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Response } from "express";
|
||||
|
||||
export type GenericErrorResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type RespondWithGenericErrorParams = {
|
||||
res: Response;
|
||||
response: GenericErrorResponse;
|
||||
statusCode: number;
|
||||
};
|
||||
export function respondWithGenericError({
|
||||
res,
|
||||
response,
|
||||
statusCode,
|
||||
}: RespondWithGenericErrorParams) {
|
||||
res.status(statusCode).json(response);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Response } from "express";
|
||||
|
||||
export type ValidationErrorResponse = {
|
||||
message: string;
|
||||
issues: {
|
||||
field: string;
|
||||
message: string;
|
||||
}[];
|
||||
};
|
||||
type RespondWithValidationErrorParams = {
|
||||
res: Response;
|
||||
response: ValidationErrorResponse;
|
||||
};
|
||||
export function respondWithValidationError({
|
||||
res,
|
||||
response,
|
||||
}: RespondWithValidationErrorParams) {
|
||||
res.status(400).json(response);
|
||||
}
|
||||
15
src/shared/infrastructure/http/routes.ts
Normal file
15
src/shared/infrastructure/http/routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from "express";
|
||||
import authRoutes from "@/modules/auth/infrastructure/http/auth.routes.js";
|
||||
import helloWorldRoutes from "@/modules/hello-world/infrastructure/http/hello-world.routes.js";
|
||||
import { attachRequestContext } from "./middlewares/attachRequestContext.js";
|
||||
import { attachSession } from "./middlewares/attachSession.js";
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.use(attachRequestContext);
|
||||
routes.use(attachSession);
|
||||
|
||||
routes.use("/auth", authRoutes);
|
||||
routes.use("/hello-world", helloWorldRoutes);
|
||||
|
||||
export default routes;
|
||||
30
src/shared/infrastructure/logger/ConsoleLogger.ts
Normal file
30
src/shared/infrastructure/logger/ConsoleLogger.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
ILogger,
|
||||
LogMessage,
|
||||
} from "@/shared/application/ports/ILogger.js";
|
||||
|
||||
function messageBuilder({ message, module, context }: LogMessage): string {
|
||||
const fullMessage = [message];
|
||||
const localDateTime = new Date().toLocaleString();
|
||||
if (context) {
|
||||
fullMessage.push(`(${context.ip}) [${context.method} ${context.route}]`);
|
||||
}
|
||||
if (module) {
|
||||
fullMessage.push(`[${module}]`);
|
||||
}
|
||||
return `[${localDateTime}] ${fullMessage.reverse().join(" ")}`;
|
||||
}
|
||||
|
||||
export class ConsoleLogger implements ILogger {
|
||||
info(message: LogMessage): void {
|
||||
console.log(messageBuilder(message));
|
||||
}
|
||||
|
||||
error(message: LogMessage): void {
|
||||
console.error(messageBuilder(message));
|
||||
}
|
||||
|
||||
warn(message: LogMessage): void {
|
||||
console.warn(messageBuilder(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { InMemoryRepository } from "./InMemoryRepository.js";
|
||||
|
||||
type TestEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
age: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
class TestRepository extends InMemoryRepository<TestEntity> {}
|
||||
|
||||
describe("InMemoryRepository (Generic) - Comprehensive Tests", () => {
|
||||
let repo: TestRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new TestRepository();
|
||||
});
|
||||
|
||||
describe("Persistence Operations", () => {
|
||||
it("should save and find an entity by ID", async () => {
|
||||
const id = repo.generateId();
|
||||
const entity: TestEntity = {
|
||||
id,
|
||||
name: "Test",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await repo.save(entity);
|
||||
const found = await repo.findById(id);
|
||||
|
||||
expect(found).toEqual(entity);
|
||||
});
|
||||
|
||||
it("should update an existing entity with the same ID", async () => {
|
||||
const id = repo.generateId();
|
||||
const entity: TestEntity = {
|
||||
id,
|
||||
name: "Old Name",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await repo.save(entity);
|
||||
|
||||
// Modify and save again
|
||||
entity.name = "New Name";
|
||||
await repo.save(entity);
|
||||
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.name).toBe("New Name");
|
||||
|
||||
// Ensure no duplicate records
|
||||
const all = await repo.findAll();
|
||||
expect(all.total).toBe(1);
|
||||
});
|
||||
|
||||
it("should return null if finding by non-existent ID", async () => {
|
||||
const found = await repo.findById("non-existent-id");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query Operations", () => {
|
||||
it("should find one entity by filter criteria", async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Unique",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Other",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const found = await repo.findOne({ name: "Unique" });
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("should return null if findOne matches nothing", async () => {
|
||||
const found = await repo.findOne({ name: "NonExistent" });
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exact Match Filtering", () => {
|
||||
it("should filter by string (exact match)", async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Alice",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Bob",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.findAll({ name: "Alice" });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data.at(0)?.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("should filter by number (exact match)", async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Alice",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Bob",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.findAll({ age: 25 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data.at(0)?.age).toBe(25);
|
||||
});
|
||||
|
||||
it("should filter by boolean (true)", async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Alice",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Bob",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.findAll({ isActive: true });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data.at(0)?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by boolean (false)", async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Alice",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Bob",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.findAll({ isActive: false });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data.at(0)?.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should filter by Date (exact match)", async () => {
|
||||
const date1 = new Date("2023-01-01T00:00:00Z");
|
||||
const date2 = new Date("2023-01-02T00:00:00Z");
|
||||
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Alice",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: date1,
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Bob",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: date2,
|
||||
});
|
||||
|
||||
const result = await repo.findAll({ createdAt: date1 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data.at(0)?.createdAt).toEqual(date1);
|
||||
});
|
||||
|
||||
it("should ignore undefined filter properties", async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Alice",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Bob",
|
||||
age: 30,
|
||||
isActive: false,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.findAll({ name: undefined });
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Range Filtering - Number", () => {
|
||||
beforeEach(async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "Kid",
|
||||
age: 10,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Teen",
|
||||
age: 15,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await repo.save({
|
||||
id: "3",
|
||||
name: "Adult",
|
||||
age: 25,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by number range (from only - tail case)", async () => {
|
||||
// age >= 15
|
||||
const result = await repo.findAll({ age: { from: 15 } });
|
||||
expect(result.data).toHaveLength(2); // Teen, Adult
|
||||
expect(result.data.map((i) => i.age).sort()).toEqual([15, 25]);
|
||||
});
|
||||
|
||||
it("should filter by number range (to only - head case)", async () => {
|
||||
// age <= 15
|
||||
const result = await repo.findAll({ age: { to: 15 } });
|
||||
expect(result.data).toHaveLength(2); // Kid, Teen
|
||||
expect(result.data.map((i) => i.age).sort()).toEqual([10, 15]);
|
||||
});
|
||||
|
||||
it("should filter by number range (from and to - middle case)", async () => {
|
||||
// 12 <= age <= 20
|
||||
const result = await repo.findAll({ age: { from: 12, to: 20 } });
|
||||
expect(result.data).toHaveLength(1); // Teen
|
||||
expect(result.data.at(0)?.age).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Range Filtering - Date", () => {
|
||||
const d1 = new Date("2023-01-01T10:00:00Z");
|
||||
const d2 = new Date("2023-01-02T10:00:00Z");
|
||||
const d3 = new Date("2023-01-03T10:00:00Z");
|
||||
|
||||
beforeEach(async () => {
|
||||
await repo.save({
|
||||
id: "1",
|
||||
name: "First",
|
||||
age: 20,
|
||||
isActive: true,
|
||||
createdAt: d1,
|
||||
});
|
||||
await repo.save({
|
||||
id: "2",
|
||||
name: "Second",
|
||||
age: 20,
|
||||
isActive: true,
|
||||
createdAt: d2,
|
||||
});
|
||||
await repo.save({
|
||||
id: "3",
|
||||
name: "Third",
|
||||
age: 20,
|
||||
isActive: true,
|
||||
createdAt: d3,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by date range (from only - tail case)", async () => {
|
||||
// date >= d2
|
||||
const result = await repo.findAll({ createdAt: { from: d2 } });
|
||||
expect(result.data).toHaveLength(2); // Second, Third
|
||||
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
||||
d2.getTime(),
|
||||
d3.getTime(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter by date range (to only - head case)", async () => {
|
||||
// date <= d2
|
||||
const result = await repo.findAll({ createdAt: { to: d2 } });
|
||||
expect(result.data).toHaveLength(2); // First, Second
|
||||
expect(result.data.map((i) => i.createdAt.getTime()).sort()).toEqual([
|
||||
d1.getTime(),
|
||||
d2.getTime(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter by date range (from and to - middle case)", async () => {
|
||||
// d1 <= date <= d2
|
||||
const result = await repo.findAll({ createdAt: { from: d1, to: d2 } });
|
||||
expect(result.data).toHaveLength(2); // First, Second
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination with Filtering", () => {
|
||||
beforeEach(async () => {
|
||||
// Create 10 items
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await repo.save({
|
||||
id: i.toString(),
|
||||
name: i % 2 === 0 ? "Even" : "Odd",
|
||||
age: i * 10, // 10, 20, ..., 100
|
||||
isActive: true,
|
||||
createdAt: new Date(`2023-01-${i.toString().padStart(2, "0")}`),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should paginate exact match results", async () => {
|
||||
// Filter: name = "Even" (5 items: 2, 4, 6, 8, 10)
|
||||
// Page 1: limit 2 -> [2, 4]
|
||||
const page1 = await repo.findAll(
|
||||
{ name: "Even" },
|
||||
{ offset: 0, limit: 2 },
|
||||
);
|
||||
expect(page1.total).toBe(5);
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page1.data.at(0)?.id).toBe("2");
|
||||
expect(page1.data.at(1)?.id).toBe("4");
|
||||
|
||||
// Page 2: offset 2, limit 2 -> [6, 8]
|
||||
const page2 = await repo.findAll(
|
||||
{ name: "Even" },
|
||||
{ offset: 2, limit: 2 },
|
||||
);
|
||||
expect(page2.data).toHaveLength(2);
|
||||
expect(page2.data.at(0)?.id).toBe("6");
|
||||
expect(page2.data.at(1)?.id).toBe("8");
|
||||
});
|
||||
|
||||
it("should paginate number range results", async () => {
|
||||
// Filter: age >= 50 (6 items: 50, 60, 70, 80, 90, 100)
|
||||
// Page 1: limit 3 -> [50, 60, 70]
|
||||
const result = await repo.findAll(
|
||||
{ age: { from: 50 } },
|
||||
{ offset: 0, limit: 3 },
|
||||
);
|
||||
expect(result.total).toBe(6);
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data.map((i) => i.age)).toEqual([50, 60, 70]);
|
||||
});
|
||||
|
||||
it("should paginate date range results", async () => {
|
||||
// Filter: date <= 2023-01-05 (5 items: 1, 2, 3, 4, 5)
|
||||
const targetDate = new Date("2023-01-05");
|
||||
// Page 2: offset 2, limit 2 -> [3, 4]
|
||||
const result = await repo.findAll(
|
||||
{ createdAt: { to: targetDate } },
|
||||
{ offset: 2, limit: 2 },
|
||||
);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.at(0)?.id).toBe("3");
|
||||
expect(result.data.at(1)?.id).toBe("4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Utility Operations", () => {
|
||||
it("should generate unique IDs", () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
ids.add(repo.generateId());
|
||||
}
|
||||
expect(ids.size).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
FilterCriteria,
|
||||
FilterRange,
|
||||
IBaseRepository,
|
||||
PaginationOptions,
|
||||
WithPagination,
|
||||
} from "@/shared/core/IBaseRepository.js";
|
||||
|
||||
export class InMemoryRepository<T extends { id: string }>
|
||||
implements IBaseRepository<T>
|
||||
{
|
||||
protected items: T[] = [];
|
||||
|
||||
async save(entity: T): Promise<T | null> {
|
||||
const index = this.items.findIndex((item) => item.id === entity.id);
|
||||
if (index !== -1) {
|
||||
this.items[index] = entity;
|
||||
} else {
|
||||
this.items.push(entity);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<T | null> {
|
||||
return this.items.find((item) => item.id === id) || null;
|
||||
}
|
||||
|
||||
async findOne(criteria: FilterCriteria<T>): Promise<T | null> {
|
||||
const filtered = this.applyFilters(this.items, criteria);
|
||||
return filtered[0] || null;
|
||||
}
|
||||
|
||||
async findAll(
|
||||
criteria?: FilterCriteria<T>,
|
||||
paginationOptions?: PaginationOptions,
|
||||
): Promise<WithPagination<T>> {
|
||||
let filtered = this.items;
|
||||
|
||||
if (criteria) {
|
||||
filtered = this.applyFilters(filtered, criteria);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
if (paginationOptions) {
|
||||
const { offset, limit } = paginationOptions;
|
||||
filtered = filtered.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
generateId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
protected applyFilters(items: T[], criteria: FilterCriteria<T>): T[] {
|
||||
return items.filter((item) => {
|
||||
return Object.entries(criteria).every(([key, value]) => {
|
||||
const itemValue = item[key as keyof T];
|
||||
|
||||
if (value === undefined) return true;
|
||||
|
||||
// Handle Date Range
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
("from" in value || "to" in value) &&
|
||||
((value as FilterRange<Date>).from instanceof Date ||
|
||||
(value as FilterRange<Date>).to instanceof Date) &&
|
||||
itemValue instanceof Date
|
||||
) {
|
||||
const range = value as FilterRange<Date>;
|
||||
if (range.from && itemValue < range.from) return false;
|
||||
if (range.to && itemValue > range.to) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Number Range
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(typeof (value as FilterRange<number>).from === "number" ||
|
||||
typeof (value as FilterRange<number>).to === "number") &&
|
||||
typeof itemValue === "number"
|
||||
) {
|
||||
const range = value as FilterRange<number>;
|
||||
if (range.from !== undefined && itemValue < range.from) return false;
|
||||
if (range.to !== undefined && itemValue > range.to) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Exact Match
|
||||
return itemValue === value;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import { PrismaClient as PrismaClientLib } from "@/generated/prisma/client.js";
|
||||
import type { IConfigService } from "@/shared/application/ports/IConfigService.js";
|
||||
import { SharedDomain } from "@/shared/application/ports/shared.symbols.js";
|
||||
|
||||
export type PrismaClient = PrismaClientLib;
|
||||
|
||||
@injectable()
|
||||
export class PrismaClientWrapper {
|
||||
private readonly client: PrismaClientLib;
|
||||
|
||||
constructor(
|
||||
@inject(SharedDomain.IConfigService)
|
||||
private readonly configService: IConfigService,
|
||||
) {
|
||||
const connectionString = this.configService.get("POSTGRES_URL");
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString,
|
||||
});
|
||||
|
||||
this.client = new PrismaClientLib({
|
||||
adapter,
|
||||
});
|
||||
}
|
||||
|
||||
getClient(): PrismaClientLib {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
generateId(): string {
|
||||
return uuidv7();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user