Checkpoint: Évolution v2 complète : nouveau profil Adhérent FEHAP, connexion locale par email/mot de passe (bcrypt), création manuelle d'utilisateurs par les gestionnaires, affectation d'établissements aux adhérents, page de choix de connexion (/login), refonte de la page Admin avec CRUD complet. 33 tests Vitest passés, zéro erreur TypeScript.

This commit is contained in:
Manus
2026-04-16 04:02:22 -04:00
parent 522d2e3c87
commit a58296774f
17 changed files with 2321 additions and 146 deletions

View File

@@ -69,7 +69,7 @@ export async function updateUserCgu(userId: number) {
await db.update(users).set({ cguAccepted: true, cguAcceptedAt: new Date() }).where(eq(users.id, userId));
}
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire") {
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire" | "adherent") {
const db = await getDb();
if (!db) return;
await db.update(users).set({ sonumRole }).where(eq(users.id, userId));
@@ -390,3 +390,206 @@ export async function getDemandeById(id: number) {
const result = await db.select().from(demandesContact).where(eq(demandesContact.id, id)).limit(1);
return result[0] ?? null;
}
// ─── Auth locale ──────────────────────────────────────────────────────────────
import { localCredentials, userEtablissements } from "../drizzle/schema";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
/** Crée un utilisateur local (sans openId OAuth) avec un mot de passe hashé. */
export async function createLocalUser(data: {
name: string;
email: string;
sonumRole: "referent" | "gestionnaire" | "adherent";
password: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier unicité email
const existing = await db.select().from(users).where(eq(users.email, data.email)).limit(1);
if (existing.length > 0) throw new Error("EMAIL_EXISTS");
// openId synthétique pour les comptes locaux
const syntheticOpenId = `local_${nanoid(16)}`;
const passwordHash = await bcrypt.hash(data.password, 12);
const insertResult = await db.insert(users).values({
openId: syntheticOpenId,
name: data.name,
email: data.email,
loginMethod: "local",
sonumRole: data.sonumRole,
cguAccepted: false,
lastSignedIn: new Date(),
});
const userId = Number((insertResult as any)[0]?.insertId ?? 0);
if (!userId) throw new Error("Failed to create user");
await db.insert(localCredentials).values({ userId, passwordHash });
return userId;
}
/** Authentifie un utilisateur par email + mot de passe. Retourne l'utilisateur ou null. */
export async function authenticateLocalUser(email: string, password: string) {
const db = await getDb();
if (!db) return null;
const result = await db
.select({
user: users,
passwordHash: localCredentials.passwordHash,
})
.from(users)
.innerJoin(localCredentials, eq(localCredentials.userId, users.id))
.where(eq(users.email, email))
.limit(1);
if (!result.length) return null;
const { user, passwordHash } = result[0];
const valid = await bcrypt.compare(password, passwordHash);
if (!valid) return null;
// Mettre à jour lastSignedIn
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user.id));
return user;
}
/** Vérifie si un utilisateur possède des credentials locaux. */
export async function hasLocalCredentials(userId: number) {
const db = await getDb();
if (!db) return false;
const result = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
return result.length > 0;
}
/** Met à jour le mot de passe d'un utilisateur. */
export async function updateLocalPassword(userId: number, newPassword: string) {
const db = await getDb();
if (!db) return;
const passwordHash = await bcrypt.hash(newPassword, 12);
const existing = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
if (existing.length > 0) {
await db.update(localCredentials).set({ passwordHash, updatedAt: new Date() }).where(eq(localCredentials.userId, userId));
} else {
await db.insert(localCredentials).values({ userId, passwordHash });
}
}
/** Met à jour les informations d'un utilisateur. */
export async function updateUser(userId: number, data: {
name?: string;
email?: string;
sonumRole?: "referent" | "gestionnaire" | "adherent";
}) {
const db = await getDb();
if (!db) return;
await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, userId));
}
/** Supprime un utilisateur et ses credentials locaux. */
export async function deleteUser(userId: number) {
const db = await getDb();
if (!db) return;
await db.delete(localCredentials).where(eq(localCredentials.userId, userId));
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
await db.delete(users).where(eq(users.id, userId));
}
// ─── Affectations Adhérents ↔ Établissements ─────────────────────────────────
/** Retourne les établissements affectés à un adhérent. */
export async function getEtablissementsByAdherent(userId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: etablissements.id,
finess: etablissements.finess,
nom: etablissements.nom,
region: etablissements.region,
departement: etablissements.departement,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
referentId: etablissements.referentId,
visibilite: etablissements.visibilite,
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
})
.from(userEtablissements)
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id))
.where(eq(userEtablissements.userId, userId))
.orderBy(etablissements.nom);
}
/** Retourne les IDs des établissements affectés à un adhérent. */
export async function getAffectationsByUser(userId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({ etablissementId: userEtablissements.etablissementId })
.from(userEtablissements)
.where(eq(userEtablissements.userId, userId));
return result.map((r) => r.etablissementId);
}
/** Affecte un établissement à un adhérent (idempotent). */
export async function assignEtablissementToUser(userId: number, etablissementId: number) {
const db = await getDb();
if (!db) return;
const existing = await db
.select()
.from(userEtablissements)
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)))
.limit(1);
if (existing.length === 0) {
await db.insert(userEtablissements).values({ userId, etablissementId });
}
}
/** Retire un établissement d'un adhérent. */
export async function removeEtablissementFromUser(userId: number, etablissementId: number) {
const db = await getDb();
if (!db) return;
await db
.delete(userEtablissements)
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)));
}
/** Remplace toutes les affectations d'un adhérent par une nouvelle liste. */
export async function setAffectationsForUser(userId: number, etablissementIds: number[]) {
const db = await getDb();
if (!db) return;
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
if (etablissementIds.length > 0) {
await db.insert(userEtablissements).values(etablissementIds.map((eid) => ({ userId, etablissementId: eid })));
}
}
/** Retourne tous les utilisateurs avec leurs affectations. */
export async function getAllUsersWithAffectations() {
const db = await getDb();
if (!db) return [];
const allUsers = await db.select().from(users).orderBy(users.name);
const allAffectations = await db
.select({
userId: userEtablissements.userId,
etablissementId: userEtablissements.etablissementId,
etablissementNom: etablissements.nom,
})
.from(userEtablissements)
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id));
return allUsers.map((u) => ({
...u,
etablissements: allAffectations
.filter((a) => a.userId === u.id)
.map((a) => ({ id: a.etablissementId, nom: a.etablissementNom })),
hasLocalCredentials: false, // sera enrichi côté router si besoin
}));
}

View File

@@ -1,14 +1,19 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
createBlocFonctionnel,
assignEtablissementToUser,
authenticateLocalUser,
createDemandeContact,
createBlocFonctionnel,
createEditeur,
createLocalUser,
createSolution,
deleteLogicielEtablissement,
deleteUser,
getAllDemandes,
getAllEtablissements,
getAllUsers,
getAllUsersWithAffectations,
getAffectationsByUser,
getBlocsFonctionnels,
getConsultationCount,
getConsultationsList,
@@ -17,11 +22,16 @@ import {
getDemandesRecuesParEtablissement,
getEditeurs,
getEtablissementById,
getEtablissementsByAdherent,
getEtablissementsByReferent,
getLogicielsByEtablissement,
getSolutions,
recordConsultation,
removeEtablissementFromUser,
repondreDemandeContact,
setAffectationsForUser,
updateLocalPassword,
updateUser,
updateUserCgu,
updateUserSonumRole,
upsertLogicielEtablissement,
@@ -35,6 +45,7 @@ import { notifyOwner } from "./_core/notification";
import { getDb } from "./db";
import { etablissements } from "../drizzle/schema";
import { eq } from "drizzle-orm";
import { sdk } from "./_core/sdk";
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
@@ -53,11 +64,41 @@ export const appRouter = router({
// ─── Auth ──────────────────────────────────────────────────────────────────
auth: router({
me: publicProcedure.query((opts) => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
/**
* Connexion locale par email + mot de passe.
* Crée un cookie de session identique à celui de l'OAuth.
*/
loginLocal: publicProcedure
.input(z.object({
email: z.string().email(),
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const user = await authenticateLocalUser(input.email, input.password);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect" });
}
// Créer un token de session avec l'openId de l'utilisateur local
const sessionToken = await sdk.createSessionToken(user.openId!, {
name: user.name ?? "",
});
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.cookie(COOKIE_NAME, sessionToken, {
...cookieOptions,
maxAge: 1000 * 60 * 60 * 24 * 365, // 1 an
});
return { success: true, user };
}),
}),
// ─── CGU ───────────────────────────────────────────────────────────────────
@@ -108,9 +149,21 @@ export const appRouter = router({
// ─── Établissements ────────────────────────────────────────────────────────
etablissements: router({
mesEtablissements: protectedProcedure.query(({ ctx }) =>
getEtablissementsByReferent(ctx.user.id)
),
/**
* Retourne les établissements selon le rôle :
* - référent : ses établissements
* - adhérent : ses établissements affectés
* - gestionnaire : tous
*/
mesEtablissements: protectedProcedure.query(({ ctx }) => {
if (ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin") {
return getAllEtablissements();
}
if (ctx.user.sonumRole === "adherent") {
return getEtablissementsByAdherent(ctx.user.id);
}
return getEtablissementsByReferent(ctx.user.id);
}),
all: gestionnaireProcedure.query(() => getAllEtablissements()),
@@ -119,7 +172,13 @@ export const appRouter = router({
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.id);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Vérifier visibilité
// Adhérent : vérifier qu'il a accès à cet établissement
if (ctx.user.sonumRole === "adherent") {
const affectations = await getAffectationsByUser(ctx.user.id);
if (!affectations.includes(input.id)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
@@ -192,6 +251,11 @@ export const appRouter = router({
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Adhérent : vérifier affectation
if (ctx.user.sonumRole === "adherent") {
const affectations = await getAffectationsByUser(ctx.user.id);
if (!affectations.includes(input.etablissementId)) throw new TRPCError({ code: "FORBIDDEN" });
}
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" });
}
@@ -284,7 +348,6 @@ export const appRouter = router({
message: input.message,
});
// Notification au propriétaire (gestionnaire SONUM)
await notifyOwner({
title: `Nouvelle demande de contact — ${etab.nom}`,
content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`,
@@ -321,17 +384,110 @@ export const appRouter = router({
// ─── Administration ────────────────────────────────────────────────────────
admin: router({
users: gestionnaireProcedure.query(() => getAllUsers()),
/** Liste tous les utilisateurs avec leurs établissements affectés */
users: gestionnaireProcedure.query(() => getAllUsersWithAffectations()),
/** Crée un utilisateur manuellement avec un mot de passe local */
createUser: gestionnaireProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
}))
.mutation(async ({ input }) => {
try {
const userId = await createLocalUser(input);
return { success: true, userId };
} catch (err: any) {
if (err.message === "EMAIL_EXISTS") {
throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" });
}
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message });
}
}),
/** Met à jour les informations d'un utilisateur */
updateUser: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(),
}))
.mutation(async ({ input }) => {
const { userId, ...data } = input;
await updateUser(userId, data);
return { success: true };
}),
/** Réinitialise le mot de passe d'un utilisateur local */
resetPassword: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
newPassword: z.string().min(8),
}))
.mutation(async ({ input }) => {
await updateLocalPassword(input.userId, input.newPassword);
return { success: true };
}),
/** Supprime un utilisateur */
deleteUser: gestionnaireProcedure
.input(z.object({ userId: z.number().int() }))
.mutation(async ({ input }) => {
await deleteUser(input.userId);
return { success: true };
}),
/** Ancienne procédure de mise à jour du rôle (rétrocompatibilité) */
updateRole: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
sonumRole: z.enum(["referent", "gestionnaire"]),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
}))
.mutation(async ({ input }) => {
await updateUserSonumRole(input.userId, input.sonumRole);
return { success: true };
}),
/** Retourne les établissements affectés à un utilisateur */
affectations: gestionnaireProcedure
.input(z.object({ userId: z.number().int() }))
.query(({ input }) => getAffectationsByUser(input.userId)),
/** Remplace toutes les affectations d'un adhérent */
setAffectations: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementIds: z.array(z.number().int()),
}))
.mutation(async ({ input }) => {
await setAffectationsForUser(input.userId, input.etablissementIds);
return { success: true };
}),
/** Ajoute un établissement à un utilisateur */
assignEtablissement: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
}))
.mutation(async ({ input }) => {
await assignEtablissementToUser(input.userId, input.etablissementId);
return { success: true };
}),
/** Retire un établissement d'un utilisateur */
removeEtablissement: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
}))
.mutation(async ({ input }) => {
await removeEtablissementFromUser(input.userId, input.etablissementId);
return { success: true };
}),
}),
});

249
server/sonum-v2.test.ts Normal file
View File

@@ -0,0 +1,249 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import type { User } from "../drizzle/schema";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
openId: "test-open-id",
name: "Test User",
email: "test@example.com",
loginMethod: "local",
role: "user",
sonumRole: "referent",
cguAccepted: true,
cguAcceptedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
...overrides,
};
}
function makeCtx(user: User | null = null): TrpcContext {
const cookies: Record<string, string> = {};
return {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
cookie: (name: string, value: string, _opts: unknown) => {
cookies[name] = value;
},
clearCookie: (_name: string, _opts: unknown) => {},
} as unknown as TrpcContext["res"],
};
}
// ─── Tests : auth.me ──────────────────────────────────────────────────────────
describe("auth.me", () => {
it("retourne null quand non authentifié", async () => {
const caller = appRouter.createCaller(makeCtx(null));
const result = await caller.auth.me();
expect(result).toBeNull();
});
it("retourne l'utilisateur quand authentifié", async () => {
const user = makeUser({ name: "Alice" });
const caller = appRouter.createCaller(makeCtx(user));
const result = await caller.auth.me();
expect(result?.name).toBe("Alice");
});
});
// ─── Tests : auth.logout ──────────────────────────────────────────────────────
describe("auth.logout", () => {
it("efface le cookie de session et retourne success", async () => {
const clearedCookies: string[] = [];
const ctx: TrpcContext = {
user: makeUser(),
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: {
clearCookie: (name: string) => clearedCookies.push(name),
} as unknown as TrpcContext["res"],
};
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result.success).toBe(true);
expect(clearedCookies).toContain(COOKIE_NAME);
});
});
// ─── Tests : auth.loginLocal ──────────────────────────────────────────────────
describe("auth.loginLocal", () => {
it("rejette un email invalide", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "not-an-email", password: "password123" })
).rejects.toThrow();
});
it("rejette un mot de passe vide", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "test@example.com", password: "" })
).rejects.toThrow();
});
});
// ─── Tests : cgu ─────────────────────────────────────────────────────────────
describe("cgu.status", () => {
it("retourne le statut CGU de l'utilisateur", async () => {
const user = makeUser({ cguAccepted: true });
const caller = appRouter.createCaller(makeCtx(user));
const result = await caller.cgu.status();
expect(result.accepted).toBe(true);
});
it("retourne false si CGU non acceptée", async () => {
const user = makeUser({ cguAccepted: false, cguAcceptedAt: null });
const caller = appRouter.createCaller(makeCtx(user));
const result = await caller.cgu.status();
expect(result.accepted).toBe(false);
});
it("lève UNAUTHORIZED si non authentifié", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(caller.cgu.status()).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});
// ─── Tests : gestion des rôles ────────────────────────────────────────────────
describe("admin.updateRole", () => {
it("lève FORBIDDEN pour un référent", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.updateRole({ userId: 2, sonumRole: "gestionnaire" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("lève FORBIDDEN pour un adhérent", async () => {
const user = makeUser({ sonumRole: "adherent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.updateRole({ userId: 2, sonumRole: "referent" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("admin.createUser", () => {
it("lève FORBIDDEN pour un référent", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.createUser({
name: "Test",
email: "test@test.com",
sonumRole: "adherent",
password: "password123",
})
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("valide que le mot de passe fait au moins 8 caractères", async () => {
const user = makeUser({ sonumRole: "gestionnaire" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.createUser({
name: "Test",
email: "test@test.com",
sonumRole: "adherent",
password: "short",
})
).rejects.toThrow();
});
});
// ─── Tests : affectations ─────────────────────────────────────────────────────
describe("admin.setAffectations", () => {
it("lève FORBIDDEN pour un non-gestionnaire", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.setAffectations({ userId: 2, etablissementIds: [1, 2] })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
// ─── Tests : rôles des procédures admin ──────────────────────────────────────
describe("admin.deleteUser", () => {
it("lève FORBIDDEN pour un adhérent", async () => {
const user = makeUser({ sonumRole: "adherent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.deleteUser({ userId: 2 })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("admin.resetPassword", () => {
it("lève FORBIDDEN pour un référent", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.resetPassword({ userId: 2, newPassword: "newpassword123" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
// ─── Tests : filtrage adhérent ────────────────────────────────────────────────────────────────────────────────
describe("etablissements.byId - contrôle accès adhérent", () => {
it("lève FORBIDDEN si l'adhérent tente d'accéder à un établissement non affecté", async () => {
// L'adhérent n'a aucun établissement affecté (DB vide en test)
const user = makeUser({ sonumRole: "adherent", id: 999 });
const caller = appRouter.createCaller(makeCtx(user));
// L'établissement id=1 n'est pas affecté à l'utilisateur id=999
// La procédure doit lever FORBIDDEN ou NOT_FOUND
await expect(
caller.etablissements.byId({ id: 1 })
).rejects.toThrow();
});
});
describe("admin.setAffectations - accès gestionnaire", () => {
it("accepte la requête d'un gestionnaire (ne lève pas FORBIDDEN)", async () => {
const user = makeUser({ sonumRole: "gestionnaire" });
const caller = appRouter.createCaller(makeCtx(user));
// setAffectations avec une liste vide est idempotent et ne doit pas lever FORBIDDEN
// (peut échouer sur DB indisponible mais pas sur les permissions)
try {
await caller.admin.setAffectations({ userId: 999, etablissementIds: [] });
} catch (err: any) {
// Seule une erreur de permission est inacceptable
expect(err?.code).not.toBe("FORBIDDEN");
}
});
});
describe("auth.loginLocal - validation", () => {
it("rejette un mot de passe trop court", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "user@test.com", password: "" })
).rejects.toThrow();
});
it("retourne UNAUTHORIZED pour des credentials inexistants", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "nonexistent@test.com", password: "password123" })
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});