diff --git a/packages/auth/src/application/use-cases/sign-up.use-case.test.ts b/packages/auth/src/application/use-cases/sign-up.use-case.test.ts index c2a32ff..71af2fb 100644 --- a/packages/auth/src/application/use-cases/sign-up.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-up.use-case.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { ZodError } from "zod"; +import { RecordingEventBus } from "@repo/core-testing/instrumentation"; import { signUpUseCase, signUpOutputSchema } from "@/application/use-cases/sign-up.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; @@ -11,7 +12,8 @@ describe("signUpUseCase", () => { it("creates a new user and returns session + cookie", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const useCase = signUpUseCase(users, auth); + const bus = new RecordingEventBus(); + const useCase = signUpUseCase(users, auth, bus); const result = await useCase({ username: "carol", @@ -26,13 +28,50 @@ describe("signUpUseCase", () => { it("throws AuthenticationError when username taken", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); + const bus = new RecordingEventBus(); await users.createUser(userFactory.build({ username: "alice" })); - const useCase = signUpUseCase(users, auth); + const useCase = signUpUseCase(users, auth, bus); await expect( useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }), ).rejects.toBeInstanceOf(AuthenticationError); }); + + it("publishes auth.user.signed-up after creating the user", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const bus = new RecordingEventBus(); + const useCase = signUpUseCase(users, auth, bus); + + await useCase({ + username: "dave", + password: "secret_password", + confirmPassword: "secret_password", + }); + + expect(bus.published).toHaveLength(1); + const published = bus.published[0]!; + expect(published.name).toBe("auth.user.signed-up"); + expect(published.payload).toEqual( + expect.objectContaining({ + userId: expect.any(String), + email: expect.stringMatching(/^dave@/), + }), + ); + }); + + it("does NOT publish when sign-up fails (username taken)", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const bus = new RecordingEventBus(); + await users.createUser(userFactory.build({ username: "eve" })); + + const useCase = signUpUseCase(users, auth, bus); + await expect( + useCase({ username: "eve", password: "secret_password", confirmPassword: "secret_password" }), + ).rejects.toBeInstanceOf(AuthenticationError); + expect(bus.published).toHaveLength(0); + }); }); describe("signUpUseCase output validation (R25)", () => { @@ -46,7 +85,8 @@ describe("signUpUseCase output validation (R25)", () => { createSession: async () => ({ session: { id: 123 }, cookie: null }), } as unknown as IAuthenticationService; - const useCase = signUpUseCase(users, auth); + const bus = new RecordingEventBus(); + const useCase = signUpUseCase(users, auth, bus); await expect( useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }), ).rejects.toBeInstanceOf(ZodError); diff --git a/packages/auth/src/application/use-cases/sign-up.use-case.ts b/packages/auth/src/application/use-cases/sign-up.use-case.ts index 87258e3..54bf9fe 100644 --- a/packages/auth/src/application/use-cases/sign-up.use-case.ts +++ b/packages/auth/src/application/use-cases/sign-up.use-case.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import type { IEventBus } from "@repo/core-events"; +import { userSignedUpEvent } from "../../events/user-signed-up.event"; import { AuthenticationError } from "../../entities/errors/auth"; import { cookieSchema } from "../../entities/models/cookie"; import { sessionSchema } from "../../entities/models/session"; @@ -34,6 +36,7 @@ export const signUpUseCase = ( usersRepository: IUsersRepository, authenticationService: IAuthenticationService, + bus: IEventBus, ) => async (input: SignUpInput): Promise => { const existingUser = await usersRepository.getUserByUsername(input.username); @@ -52,5 +55,13 @@ export const signUpUseCase = const { cookie, session } = await authenticationService.createSession(newUser); + // Auth is username-based — synthesize a deterministic email so the event + // payload validates against userSignedUpEventSchema.email(). + await bus.publish(userSignedUpEvent, { + userId: newUser.id, + email: `${newUser.username}@example.local`, + signedUpAt: new Date().toISOString(), + }); + return signUpOutputSchema.parse({ session, cookie }); }; diff --git a/packages/auth/src/di/bind-dev-seed.ts b/packages/auth/src/di/bind-dev-seed.ts index 337b1a6..54416a4 100644 --- a/packages/auth/src/di/bind-dev-seed.ts +++ b/packages/auth/src/di/bind-dev-seed.ts @@ -81,7 +81,7 @@ export async function bindDevSeedAuth( withCapture( logger, { feature: "auth", layer: "use-case", name: "auth.signUp" }, - signUpUseCase(repo, authService), + signUpUseCase(repo, authService, bus), ), ); const wrappedSignOut = withSpan( diff --git a/packages/auth/src/di/bind-production.ts b/packages/auth/src/di/bind-production.ts index 7fc9ef5..a212d28 100644 --- a/packages/auth/src/di/bind-production.ts +++ b/packages/auth/src/di/bind-production.ts @@ -74,7 +74,7 @@ export function bindProductionAuth( withCapture( logger, { feature: "auth", layer: "use-case", name: "auth.signUp" }, - signUpUseCase(repo, authService), + signUpUseCase(repo, authService, bus), ), ); const wrappedSignOut = withSpan( diff --git a/packages/auth/src/di/module.ts b/packages/auth/src/di/module.ts index 6785164..6a314d5 100644 --- a/packages/auth/src/di/module.ts +++ b/packages/auth/src/di/module.ts @@ -1,4 +1,5 @@ import { ContainerModule, type interfaces } from "inversify"; +import { InMemoryEventBus } from "@repo/core-events"; import type { IUsersRepository } from "../application/repositories/users.repository.interface"; import type { IAuthenticationService } from "../application/services/authentication.service.interface"; @@ -44,9 +45,13 @@ export const AuthModule = new ContainerModule((bind: interfaces.Bind) => { ); bind(AUTH_SYMBOLS.ISignUpUseCase).toDynamicValue((ctx) => + // Default fallback uses a fresh InMemoryEventBus — real cross-feature + // wiring runs through bindProductionAuth / bindDevSeedAuth where the + // bindAll() dispatcher passes a shared bus instance. signUpUseCase( ctx.container.get(AUTH_SYMBOLS.IUsersRepository), ctx.container.get(AUTH_SYMBOLS.IAuthenticationService), + new InMemoryEventBus(), ), ); diff --git a/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts b/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts index 392602b..1360432 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { RecordingEventBus } from "@repo/core-testing/instrumentation"; import { signUpController } from "@/interface-adapters/controllers/sign-up.controller"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; @@ -10,7 +11,7 @@ describe("signUpController", () => { it("returns a cookie on successful sign-up", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const useCase = signUpUseCase(users, auth); + const useCase = signUpUseCase(users, auth, new RecordingEventBus()); const controller = signUpController(useCase); const result = await controller({ @@ -25,7 +26,7 @@ describe("signUpController", () => { it("throws InputParseError when passwords do not match", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const useCase = signUpUseCase(users, auth); + const useCase = signUpUseCase(users, auth, new RecordingEventBus()); const controller = signUpController(useCase); await expect( @@ -41,7 +42,7 @@ describe("signUpController", () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); await users.createUser(userFactory.build({ username: "alice" })); - const useCase = signUpUseCase(users, auth); + const useCase = signUpUseCase(users, auth, new RecordingEventBus()); const controller = signUpController(useCase); await expect( diff --git a/packages/auth/tests/sign-in-flow.feature.test.ts b/packages/auth/tests/sign-in-flow.feature.test.ts index f67f467..cf14c0e 100644 --- a/packages/auth/tests/sign-in-flow.feature.test.ts +++ b/packages/auth/tests/sign-in-flow.feature.test.ts @@ -2,6 +2,7 @@ // Constructs the full chain via direct injection (no container rebinding). import { describe, it, expect } from "vitest"; +import { RecordingEventBus } from "@repo/core-testing/instrumentation"; import { MockUsersRepository } from "../src/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "../src/infrastructure/services/authentication.service.mock"; import { signInUseCase } from "../src/application/use-cases/sign-in.use-case"; @@ -18,7 +19,7 @@ describe("auth feature: sign-up → sign-in → sign-out", () => { const auth = new MockAuthenticationService(users); const signIn = signInController(signInUseCase(users, auth)); - const signUp = signUpController(signUpUseCase(users, auth)); + const signUp = signUpController(signUpUseCase(users, auth, new RecordingEventBus())); const signOut = signOutController(signOutUseCase(auth)); // signUp returns a cookie (presenter shape)