feat: add modular services and repositories for improved code organization

Introduce repository pattern across multiple services, including `favorites`, `tags`, `sessions`, `reports`, `auth`, and more. Decouple crypto functionalities into modular services like `HashingService`, `JwtService`, and `EncryptionService`. Improve testability and maintainability by simplifying dependencies and consolidating utility logic.
This commit is contained in:
Mathis HERRIOT
2026-01-14 12:11:39 +01:00
parent 9c45bf11e4
commit 514bd354bf
64 changed files with 1801 additions and 1295 deletions

View File

@@ -0,0 +1,64 @@
import { Injectable } from "@nestjs/common";
import { and, eq, lte } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { sessions } from "../../database/schemas";
@Injectable()
export class SessionsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: {
userId: string;
refreshToken: string;
userAgent?: string;
ipHash?: string | null;
expiresAt: Date;
}) {
const [session] = await this.databaseService.db
.insert(sessions)
.values(data)
.returning();
return session;
}
async findValidByRefreshToken(refreshToken: string) {
const result = await this.databaseService.db
.select()
.from(sessions)
.where(
and(eq(sessions.refreshToken, refreshToken), eq(sessions.isValid, true)),
)
.limit(1);
return result[0] || null;
}
async update(sessionId: string, data: any) {
const [updatedSession] = await this.databaseService.db
.update(sessions)
.set({ ...data, updatedAt: new Date() })
.where(eq(sessions.id, sessionId))
.returning();
return updatedSession;
}
async revoke(sessionId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.id, sessionId));
}
async revokeAllByUserId(userId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.userId, userId));
}
async purgeExpired(now: Date) {
return await this.databaseService.db
.delete(sessions)
.where(lte(sessions.expiresAt, now))
.returning();
}
}

View File

@@ -2,10 +2,11 @@ import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsService } from "./sessions.service";
import { SessionsRepository } from "./repositories/sessions.repository";
@Module({
imports: [DatabaseModule, CryptoModule],
providers: [SessionsService],
providers: [SessionsService, SessionsRepository],
exports: [SessionsService],
})
export class SessionsModule {}

View File

@@ -13,60 +13,45 @@ jest.mock("jose", () => ({
import { UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { CryptoService } from "../crypto/crypto.service";
import { DatabaseService } from "../database/database.service";
import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsService } from "./sessions.service";
import { SessionsRepository } from "./repositories/sessions.repository";
describe("SessionsService", () => {
let service: SessionsService;
let repository: SessionsRepository;
const mockDb = {
insert: jest.fn(),
select: jest.fn(),
const mockSessionsRepository = {
create: jest.fn(),
findValidByRefreshToken: jest.fn(),
update: jest.fn(),
revoke: jest.fn(),
revokeAllByUserId: jest.fn(),
};
const mockCryptoService = {
generateJwt: jest.fn().mockResolvedValue("mock-jwt"),
hashIp: jest.fn().mockResolvedValue("mock-ip-hash"),
const mockHashingService = {
hashIp: jest.fn().mockResolvedValue("hashed-ip"),
};
const mockJwtService = {
generateJwt: jest.fn().mockResolvedValue("new-token"),
};
beforeEach(async () => {
jest.clearAllMocks();
const chain = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockImplementation(function (cb) {
return Promise.resolve(this).then(cb);
}),
};
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
for (const mock of Object.values(chain)) {
if (mock !== chain.then) {
mock.mockImplementation(mockImplementation);
}
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({
providers: [
SessionsService,
{ provide: DatabaseService, useValue: { db: mockDb } },
{ provide: CryptoService, useValue: mockCryptoService },
{ provide: SessionsRepository, useValue: mockSessionsRepository },
{ provide: HashingService, useValue: mockHashingService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<SessionsService>(SessionsService);
repository = module.get<SessionsRepository>(SessionsRepository);
});
it("should be defined", () => {
@@ -75,13 +60,10 @@ describe("SessionsService", () => {
describe("createSession", () => {
it("should create a session", async () => {
mockDb.returning.mockResolvedValue([{ id: "s1", refreshToken: "mock-jwt" }]);
mockSessionsRepository.create.mockResolvedValue({ id: "s1" });
const result = await service.createSession("u1", "agent", "1.2.3.4");
expect(result.id).toBe("s1");
expect(mockCryptoService.generateJwt).toHaveBeenCalledWith(
{ sub: "u1", type: "refresh" },
"7d",
);
expect(result).toEqual({ id: "s1" });
expect(repository.create).toHaveBeenCalled();
});
});
@@ -89,36 +71,46 @@ describe("SessionsService", () => {
it("should refresh a valid session", async () => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 1);
const session = { id: "s1", userId: "u1", expiresAt, isValid: true };
mockDb.where.mockImplementation(() => {
const chain = {
limit: jest.fn().mockReturnThis(),
returning: jest
.fn()
.mockResolvedValue([{ ...session, refreshToken: "new-jwt" }]),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockResolvedValue(session),
};
return Object.assign(Promise.resolve([session]), chain);
mockSessionsRepository.findValidByRefreshToken.mockResolvedValue({
id: "s1",
userId: "u1",
expiresAt,
});
mockSessionsRepository.update.mockResolvedValue({ id: "s1", refreshToken: "new-token" });
const result = await service.refreshSession("old-jwt");
expect(result.refreshToken).toBe("new-jwt");
const result = await service.refreshSession("old-token");
expect(result.refreshToken).toBe("new-token");
expect(repository.update).toHaveBeenCalled();
});
it("should throw UnauthorizedException if session not found", async () => {
mockDb.where.mockImplementation(() => {
const chain = {
limit: jest.fn().mockReturnThis(),
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
then: jest.fn().mockResolvedValue(null),
};
return Object.assign(Promise.resolve([]), chain);
});
it("should throw if session not found", async () => {
mockSessionsRepository.findValidByRefreshToken.mockResolvedValue(null);
await expect(service.refreshSession("invalid")).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw and revoke if session expired", async () => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() - 1);
mockSessionsRepository.findValidByRefreshToken.mockResolvedValue({
id: "s1",
userId: "u1",
expiresAt,
});
await expect(service.refreshSession("expired")).rejects.toThrow(
UnauthorizedException,
);
expect(repository.revoke).toHaveBeenCalledWith("s1");
});
});
describe("revokeSession", () => {
it("should revoke a session", async () => {
await service.revokeSession("s1");
expect(repository.revoke).toHaveBeenCalledWith("s1");
});
});
});

View File

@@ -1,48 +1,36 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { CryptoService } from "../crypto/crypto.service";
import { DatabaseService } from "../database/database.service";
import { sessions } from "../database/schemas";
import { HashingService } from "../crypto/services/hashing.service";
import { JwtService } from "../crypto/services/jwt.service";
import { SessionsRepository } from "./repositories/sessions.repository";
@Injectable()
export class SessionsService {
constructor(
private readonly databaseService: DatabaseService,
private readonly cryptoService: CryptoService,
private readonly sessionsRepository: SessionsRepository,
private readonly hashingService: HashingService,
private readonly jwtService: JwtService,
) {}
async createSession(userId: string, userAgent?: string, ip?: string) {
const refreshToken = await this.cryptoService.generateJwt(
const refreshToken = await this.jwtService.generateJwt(
{ sub: userId, type: "refresh" },
"7d",
);
const ipHash = ip ? await this.cryptoService.hashIp(ip) : null;
const ipHash = ip ? await this.hashingService.hashIp(ip) : null;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const [session] = await this.databaseService.db
.insert(sessions)
.values({
userId,
refreshToken,
userAgent,
ipHash,
expiresAt,
})
.returning();
return session;
return await this.sessionsRepository.create({
userId,
refreshToken,
userAgent,
ipHash,
expiresAt,
});
}
async refreshSession(oldRefreshToken: string) {
const session = await this.databaseService.db
.select()
.from(sessions)
.where(
and(eq(sessions.refreshToken, oldRefreshToken), eq(sessions.isValid, true)),
)
.limit(1)
.then((res) => res[0]);
const session = await this.sessionsRepository.findValidByRefreshToken(oldRefreshToken);
if (!session || session.expiresAt < new Date()) {
if (session) {
@@ -52,37 +40,24 @@ export class SessionsService {
}
// Rotation du refresh token
const newRefreshToken = await this.cryptoService.generateJwt(
const newRefreshToken = await this.jwtService.generateJwt(
{ sub: session.userId, type: "refresh" },
"7d",
);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const [updatedSession] = await this.databaseService.db
.update(sessions)
.set({
refreshToken: newRefreshToken,
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, session.id))
.returning();
return updatedSession;
return await this.sessionsRepository.update(session.id, {
refreshToken: newRefreshToken,
expiresAt,
});
}
async revokeSession(sessionId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.id, sessionId));
await this.sessionsRepository.revoke(sessionId);
}
async revokeAllUserSessions(userId: string) {
await this.databaseService.db
.update(sessions)
.set({ isValid: false, updatedAt: new Date() })
.where(eq(sessions.userId, userId));
await this.sessionsRepository.revokeAllByUserId(userId);
}
}