refactor(auth): unify use-case I/O schemas + presenter + feature error map

Per Plan 9 (spec R1-R28):
- Use cases: input + output schemas (signIn, signUp); input-only for
  signOut (void output). Use case body validates output via
  outputSchema.parse before returning.
- Controllers: receive `unknown`; safeParse with the use-case schema;
  presenter (returning cookie) for signIn/signUp; void return for
  signOut.
- New integrations/api/procedures.ts with authProcedure built via
  defineErrorMiddleware([[InputParseError,"BAD_REQUEST"],
  [AuthenticationError,"UNAUTHORIZED"], [UnauthenticatedError,
  "UNAUTHORIZED"], [UnauthorizedError,"FORBIDDEN"]]).
- Router uses authProcedure + .input(xInputSchema) for every procedure.
- src/index.ts exports schemas + types + IUseCase/IController aliases.
- package.json gains ./ui subpath; src/ui/index.ts placeholder
  (auth has no query builders today).
- New tests: R25 output-validation per use case (signIn, signUp);
  R26 router error-mapping (UNAUTHORIZED on missing user,
  BAD_REQUEST on schema fail).

Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1–R6, R8–R15, R18, R19, R22–R26
This commit is contained in:
2026-05-06 12:58:10 +02:00
parent aac37fd9af
commit 2bbec70a4e
21 changed files with 353 additions and 176 deletions

View File

@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./ui": "./src/ui/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts",
"./di/bind-production": "./src/di/bind-production.ts"

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from "vitest";
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
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 } from "@/entities/errors/auth";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import { userFactory } from "@/__factories__/user.factory";
describe("signInUseCase", () => {
@@ -45,3 +46,29 @@ describe("signInUseCase", () => {
).rejects.toBeInstanceOf(AuthenticationError);
});
});
describe("signInUseCase output validation (R25)", () => {
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);
await expect(useCase({ username: "alice", password: "x" })).rejects.toThrow(/parse|invalid/i);
});
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);
});
});

View File

@@ -1,25 +1,37 @@
import { z } from "zod";
import { AuthenticationError } from "../../entities/errors/auth";
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import { cookieSchema } from "../../entities/models/cookie";
import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signInInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
})
.strict();
export type SignInInput = z.infer<typeof signInInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const signInUseCase =
(
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
) =>
async (input: {
username: string;
password: string;
}): Promise<{ session: Session; cookie: Cookie }> => {
(usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
async (input: SignInInput): Promise<SignInOutput> => {
const existingUser = await usersRepository.getUserByUsername(input.username);
if (!existingUser) {
throw new AuthenticationError("User does not exist");
}
const validPassword = await authenticationService.verifyPassword(
existingUser.passwordHash,
input.password,
@@ -27,6 +39,6 @@ export const signInUseCase =
if (!validPassword) {
throw new AuthenticationError("Incorrect username or password");
}
return await authenticationService.createSession(existingUser);
const result = await authenticationService.createSession(existingUser);
return signInOutputSchema.parse(result);
};

View File

@@ -4,13 +4,12 @@ import { MockUsersRepository } from "@/infrastructure/repositories/users.reposit
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
describe("signOutUseCase", () => {
it("returns a blank cookie", async () => {
it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const result = await useCase("session_1");
expect(result.blankCookie.name).toBe("session");
expect(result.blankCookie.value).toBe("");
const result = await useCase({ sessionId: "session_1" });
expect(result).toBeUndefined();
});
});

View File

@@ -1,10 +1,17 @@
import type { Cookie } from "../../entities/models/cookie";
import { z } from "zod";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
export type SignOutInput = z.infer<typeof signOutInputSchema>;
// No xOutputSchema — use case returns void.
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
export const signOutUseCase =
(authenticationService: IAuthenticationService) =>
async (sessionId: string): Promise<{ blankCookie: Cookie }> => {
return await authenticationService.invalidateSession(sessionId);
async (input: SignOutInput): Promise<void> => {
await authenticationService.invalidateSession(input.sessionId);
};

View File

@@ -1,12 +1,13 @@
import { describe, it, expect } from "vitest";
import { signUpUseCase } from "@/application/use-cases/sign-up.use-case";
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";
import { AuthenticationError } from "@/entities/errors/auth";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import { userFactory } from "@/__factories__/user.factory";
describe("signUpUseCase", () => {
it("creates a new user and returns session + cookie + user", async () => {
it("creates a new user and returns session + cookie", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth);
@@ -14,10 +15,10 @@ describe("signUpUseCase", () => {
const result = await useCase({
username: "carol",
password: "secret_password",
confirmPassword: "secret_password",
});
expect(result.user.username).toBe("carol");
expect(result.session.userId).toBe(result.user.id);
expect(result.session.userId).toBeTruthy();
expect(result.cookie.name).toBe("session");
});
@@ -28,7 +29,34 @@ describe("signUpUseCase", () => {
const useCase = signUpUseCase(users, auth);
await expect(
useCase({ username: "alice", password: "secret_password" }),
useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }),
).rejects.toBeInstanceOf(AuthenticationError);
});
});
describe("signUpUseCase output validation (R25)", () => {
it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]);
const auth = {
hashPassword: async () => "hashed_x",
generateUserId: () => "uid1",
verifyPassword: async () => true,
// session missing required fields → should fail signUpOutputSchema.parse
createSession: async () => ({ session: { id: 123 }, cookie: null }),
} as unknown as IAuthenticationService;
const useCase = signUpUseCase(users, auth);
await expect(
useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }),
).rejects.toThrow(/parse|invalid/i);
});
it("exports an output schema that mirrors the success shape", () => {
expect(signUpOutputSchema).toBeDefined();
const parsed = signUpOutputSchema.safeParse({
session: { id: "s1", userId: "u1", expiresAt: new Date() },
cookie: { name: "session", value: "s1", attributes: {} },
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -1,10 +1,33 @@
import { z } from "zod";
import { AuthenticationError } from "../../entities/errors/auth";
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import type { User } from "../../entities/models/user";
import { cookieSchema } from "../../entities/models/cookie";
import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signUpInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
.strict()
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type SignUpInput = z.infer<typeof signUpInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signUpOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignUpUseCase = ReturnType<typeof signUpUseCase>;
export const signUpUseCase =
@@ -12,14 +35,7 @@ export const signUpUseCase =
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
) =>
async (input: {
username: string;
password: string;
}): Promise<{
session: Session;
cookie: Cookie;
user: Pick<User, "id" | "username">;
}> => {
async (input: SignUpInput): Promise<SignUpOutput> => {
const existingUser = await usersRepository.getUserByUsername(input.username);
if (existingUser) {
throw new AuthenticationError("Username taken");
@@ -36,9 +52,5 @@ export const signUpUseCase =
const { cookie, session } = await authenticationService.createSession(newUser);
return {
cookie,
session,
user: { id: newUser.id, username: newUser.username },
};
return signUpOutputSchema.parse({ session, cookie });
};

View File

@@ -1,15 +1,19 @@
type CookieAttributes = {
secure?: boolean;
path?: string;
domain?: string;
sameSite?: "lax" | "strict" | "none";
httpOnly?: boolean;
maxAge?: number;
expires?: Date;
};
import { z } from "zod";
export type Cookie = {
name: string;
value: string;
attributes: CookieAttributes;
};
const cookieAttributesSchema = z.object({
secure: z.boolean().optional(),
path: z.string().optional(),
domain: z.string().optional(),
sameSite: z.enum(["lax", "strict", "none"]).optional(),
httpOnly: z.boolean().optional(),
maxAge: z.number().optional(),
expires: z.date().optional(),
});
export const cookieSchema = z.object({
name: z.string(),
value: z.string(),
attributes: cookieAttributesSchema,
});
export type Cookie = z.infer<typeof cookieSchema>;

View File

@@ -9,3 +9,29 @@ export {
} from "./entities/errors/auth";
export { InputParseError } from "./entities/errors/common";
export { SESSION_COOKIE } from "./config";
// Use case schemas + types (Plan 9 R18)
export {
signInInputSchema,
signInOutputSchema,
type SignInInput,
type SignInOutput,
type ISignInUseCase,
} from "./application/use-cases/sign-in.use-case";
export {
signUpInputSchema,
signUpOutputSchema,
type SignUpInput,
type SignUpOutput,
type ISignUpUseCase,
} from "./application/use-cases/sign-up.use-case";
export {
signOutInputSchema,
type SignOutInput,
type ISignOutUseCase,
} from "./application/use-cases/sign-out.use-case";
// Controller type aliases
export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller";
export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller";
export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller";

View File

@@ -0,0 +1,18 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
} from "../../entities/errors/auth";
import { InputParseError } from "../../entities/errors/common";
export const authProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[AuthenticationError, "UNAUTHORIZED"],
[UnauthenticatedError, "UNAUTHORIZED"],
[UnauthorizedError, "FORBIDDEN"],
]),
);

View File

@@ -1,5 +1,13 @@
import { describe, it, expect } from "vitest";
import { authRouter } from "./router";
import { describe, it, expect, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { authRouter } from "@/integrations/api/router";
import { authContainer } from "@/di/container";
import { AUTH_SYMBOLS } from "@/di/symbols";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
describe("authRouter", () => {
it("exposes signIn, signUp, signOut procedures", () => {
@@ -20,3 +28,45 @@ describe("authRouter", () => {
expect(result.name).toBe("session");
});
});
describe("authRouter (R26 error mapping)", () => {
beforeEach(() => {
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
}
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
}
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
authContainer
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
.toConstantValue(auth);
});
it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({ username: "ghost", password: "long-enough" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("UNAUTHORIZED");
}
});
it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({ username: "ab", password: "x" } as unknown as {
username: string;
password: string;
});
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
});

View File

@@ -1,43 +1,33 @@
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import { router } from "@repo/core-shared/trpc/init";
import { authContainer } from "../../di/container";
import { AUTH_SYMBOLS } from "../../di/symbols";
import { signInInputSchema } from "../../application/use-cases/sign-in.use-case";
import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case";
import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case";
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
import { authProcedure } from "./procedures";
export const authRouter = router({
signIn: publicProcedure
.input(
z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
}),
)
.mutation(({ input }) => {
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
return ctrl(input);
}),
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
return ctrl(input);
}),
signUp: publicProcedure
.input(
z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
}),
)
.mutation(({ input }) => {
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
return ctrl(input);
}),
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
return ctrl(input);
}),
signOut: publicProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(({ input }) => {
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
return ctrl(input.sessionId);
}),
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
return ctrl(input);
}),
});
export type AuthRouter = typeof authRouter;

View File

@@ -1,47 +1,45 @@
import { describe, it, expect } from "vitest";
import { signInController } from "@/interface-adapters/controllers/sign-in.controller";
import { signInUseCase } 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 { signInUseCase } from "@/application/use-cases/sign-in.use-case";
import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory";
describe("signInController", () => {
it("returns a cookie on valid credentials", async () => {
it("returns a cookie on successful sign-in", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const seedUser = userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
});
const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" });
await users.createUser(seedUser);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
const cookie = await controller({ username: "alice", password: "testpassword" });
expect(cookie.name).toBe("session");
const result = await controller({
username: "alice",
password: "testpassword",
});
expect(result).toBeDefined();
expect(result.name).toBeTruthy();
expect(result.value).toBeTruthy();
});
it("throws InputParseError on missing username", async () => {
it("throws InputParseError on invalid input", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
await expect(
controller({ password: "anything" }),
).rejects.toBeInstanceOf(InputParseError);
await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError on too-short password", async () => {
it("throws InputParseError when input is not an object", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth);
const controller = signInController(useCase);
await expect(
controller({ username: "alice", password: "abc" }),
).rejects.toBeInstanceOf(InputParseError);
await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -1,23 +1,23 @@
import { z } from "zod";
import { InputParseError } from "../../entities/errors/common";
import type { Cookie } from "../../entities/models/cookie";
import type { ISignInUseCase } from "../../application/use-cases/sign-in.use-case";
import {
signInInputSchema,
type ISignInUseCase,
type SignInOutput,
} from "../../application/use-cases/sign-in.use-case";
const inputSchema = z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
});
function presenter(value: SignInOutput) {
return value.cookie;
}
export type ISignInController = ReturnType<typeof signInController>;
export const signInController =
(signInUseCase: ISignInUseCase) =>
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Cookie> => {
const parsed = inputSchema.safeParse(input);
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
}
const { cookie } = await signInUseCase(parsed.data);
return cookie;
const result = await signInUseCase(parsed.data);
return presenter(result);
};

View File

@@ -1,28 +1,27 @@
import { describe, it, expect } from "vitest";
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { InputParseError } from "@/entities/errors/common";
describe("signOutController", () => {
it("returns a blank cookie", async () => {
it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const controller = signOutController(useCase);
const cookie = await controller("session_anything");
expect(cookie.name).toBe("session");
expect(cookie.value).toBe("");
const result = await controller({ sessionId: "any" });
expect(result).toBeUndefined();
});
it("throws InputParseError when sessionId is missing", async () => {
it("throws InputParseError on missing sessionId", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth);
const controller = signOutController(useCase);
await expect(controller(undefined)).rejects.toBeInstanceOf(InputParseError);
await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -1,15 +1,17 @@
import { InputParseError } from "../../entities/errors/common";
import type { Cookie } from "../../entities/models/cookie";
import type { ISignOutUseCase } from "../../application/use-cases/sign-out.use-case";
import {
signOutInputSchema,
type ISignOutUseCase,
} from "../../application/use-cases/sign-out.use-case";
export type ISignOutController = ReturnType<typeof signOutController>;
export const signOutController =
(signOutUseCase: ISignOutUseCase) =>
async (sessionId: string | undefined): Promise<Cookie> => {
if (!sessionId) {
throw new InputParseError("Must provide a session ID");
async (input: unknown): Promise<void> => {
const parsed = signOutInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-out input", { cause: parsed.error });
}
const { blankCookie } = await signOutUseCase(sessionId);
return blankCookie;
await signOutUseCase(parsed.data);
};

View File

@@ -7,7 +7,7 @@ import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory";
describe("signUpController", () => {
it("creates a new user when passwords match", async () => {
it("returns a cookie on successful sign-up", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth);
@@ -18,7 +18,8 @@ describe("signUpController", () => {
password: "secret_password",
confirmPassword: "secret_password",
});
expect(result.user.username).toBe("carol");
expect(result.name).toBe("session");
expect(result.value).toBeTruthy();
});
it("throws InputParseError when passwords do not match", async () => {
@@ -39,7 +40,6 @@ describe("signUpController", () => {
it("throws InputParseError when username is too short", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
// Pre-seed so we don't hit the taken-username error
await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(users, auth);
const controller = signUpController(useCase);

View File

@@ -1,39 +1,23 @@
import { z } from "zod";
import { InputParseError } from "../../entities/errors/common";
import type { ISignUpUseCase } from "../../application/use-cases/sign-up.use-case";
import {
signUpInputSchema,
type ISignUpUseCase,
type SignUpOutput,
} from "../../application/use-cases/sign-up.use-case";
const inputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["password"],
});
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["confirmPassword"],
});
}
});
function presenter(value: SignUpOutput) {
return value.cookie;
}
export type ISignUpController = ReturnType<typeof signUpController>;
export const signUpController =
(signUpUseCase: ISignUpUseCase) =>
async (
input: Partial<z.infer<typeof inputSchema>>,
): Promise<Awaited<ReturnType<ISignUpUseCase>>> => {
const parsed = inputSchema.safeParse(input);
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = signUpInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
}
return await signUpUseCase(parsed.data);
const result = await signUpUseCase(parsed.data);
return presenter(result);
};

View File

@@ -0,0 +1,4 @@
// Auth has no React Query option builders today (all auth procedures are
// mutations). This file is the public UI surface for future components
// and queries — extend rather than re-add to root index.ts.
export {};

View File

@@ -21,21 +21,24 @@ describe("auth feature: sign-up → sign-in → sign-out", () => {
const signUp = signUpController(signUpUseCase(users, auth));
const signOut = signOutController(signOutUseCase(auth));
const signUpResult = await signUp({
// signUp returns a cookie (presenter shape)
const signUpCookie = await signUp({
username: "newperson",
password: "verysecret",
confirmPassword: "verysecret",
});
expect(signUpResult.user.username).toBe("newperson");
const userId = signUpResult.user.id;
expect(signUpCookie.name).toBe("session");
expect(signUpCookie.value).toBeTruthy();
const signInCookie = await signIn({
username: "newperson",
password: "verysecret",
});
expect(signInCookie.value).toBe("session_" + userId);
expect(signInCookie.name).toBe("session");
expect(signInCookie.value).toBeTruthy();
const signOutResult = await signOut(signInCookie.value);
expect(signOutResult.value).toBe("");
// signOut takes { sessionId } and returns void
const signOutResult = await signOut({ sessionId: signInCookie.value });
expect(signOutResult).toBeUndefined();
});
});