import { describe, it, expect } from "vitest"; import { ZodError } from "zod"; import { signInUseCase, signInOutputSchema, } from "@/application/use-cases/sign-in.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { AuthenticationError, TooManyRequestsError, } from "@/entities/errors/auth"; import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; import { userFactory } from "@/__factories__/user.factory"; import { NoopRateLimit, InMemoryRateLimit } from "@repo/core-shared/rate-limit"; import { RecordingRateLimit } from "@repo/core-testing/rate-limit"; describe("signInUseCase", () => { it("returns a session + cookie on valid credentials", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword", }); await users.createUser(seedUser); const useCase = signInUseCase(users, auth, new NoopRateLimit()); const result = await useCase({ username: "alice", password: "testpassword", }); expect(result.session.userId).toBe(seedUser.id); expect(result.cookie.name).toBe("session"); }); it("throws AuthenticationError when user does not exist", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signInUseCase(users, auth, new NoopRateLimit()); await expect( useCase({ username: "ghost", password: "anything" }), ).rejects.toBeInstanceOf(AuthenticationError); }); it("throws AuthenticationError on wrong password", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); await users.createUser( userFactory.build({ username: "alice", passwordHash: "hashed_correctpassword", }), ); const useCase = signInUseCase(users, auth, new NoopRateLimit()); await expect( useCase({ username: "alice", password: "wrong" }), ).rejects.toBeInstanceOf(AuthenticationError); }); it("captures both ip and account consume calls via RecordingRateLimit", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const rl = new RecordingRateLimit(); const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword", }); await users.createUser(seedUser); const useCase = signInUseCase(users, auth, rl); await useCase({ username: "alice", password: "testpassword", clientIp: "1.2.3.4", }); expect(rl.consumeCalls).toHaveLength(2); expect(rl.consumeCalls[0]).toMatchObject({ budgetName: "ip", key: "signIn:ip:1.2.3.4", }); expect(rl.consumeCalls[1]).toMatchObject({ budgetName: "account", key: "signIn:account:alice", }); }); it("throws TooManyRequestsError when ip budget is exhausted", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const rl = new InMemoryRateLimit([ { name: "ip", window: "1m", budget: 1 }, { name: "account", window: "1h", budget: 10 }, ]); const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword", }); await users.createUser(seedUser); const useCase = signInUseCase(users, auth, rl); await useCase({ username: "alice", password: "testpassword", clientIp: "1.2.3.4", }); await expect( useCase({ username: "alice", password: "testpassword", clientIp: "1.2.3.4", }), ).rejects.toBeInstanceOf(TooManyRequestsError); }); it("throws TooManyRequestsError when account budget is exhausted", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const rl = new InMemoryRateLimit([ { name: "ip", window: "1m", budget: 100 }, { name: "account", window: "1h", budget: 1 }, ]); const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword", }); await users.createUser(seedUser); const useCase = signInUseCase(users, auth, rl); // First call succeeds (ip allows, account allows, credentials ok) await useCase({ username: "alice", password: "testpassword", clientIp: "1.2.3.4", }); // Second call: ip still allows (high budget), account is exhausted await expect( useCase({ username: "alice", password: "testpassword", clientIp: "5.6.7.8", }), ).rejects.toBeInstanceOf(TooManyRequestsError); }); }); describe("signInUseCase output validation", () => { it("throws when authenticationService returns a malformed session", async () => { const users = new MockUsersRepository([]); const seed = userFactory.build({ username: "alice" }); await users.createUser(seed); const auth = { verifyPassword: async () => true, // session missing required fields → should fail signInOutputSchema.parse createSession: async () => ({ session: { id: 123 }, cookie: null }), } as unknown as IAuthenticationService; const useCase = signInUseCase(users, auth, new NoopRateLimit()); await expect( useCase({ username: "alice", password: "x" }), ).rejects.toBeInstanceOf(ZodError); }); it("exports an output schema that mirrors the success shape", () => { expect(signInOutputSchema).toBeDefined(); const parsed = signInOutputSchema.safeParse({ session: { id: "s1", userId: "u1", expiresAt: new Date() }, cookie: { name: "session", value: "s1", attributes: {} }, }); expect(parsed.success).toBe(true); }); });