510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { appRouter } from "./routers";
|
|
import { buildDataPrivacyConsent } from "../shared/privacyCompliance";
|
|
import type { TrpcContext } from "./_core/context";
|
|
|
|
// Mock the database functions with all required exports
|
|
vi.mock("./db", () => ({
|
|
getDb: vi.fn(() => Promise.resolve({})),
|
|
upsertUser: vi.fn(),
|
|
getUserByEmail: vi.fn(() => Promise.resolve(undefined)),
|
|
getUserByOpenId: vi.fn(),
|
|
getUserById: vi.fn(),
|
|
getAllUsers: vi.fn(() => Promise.resolve([])),
|
|
getAdminUsers: vi.fn(() => Promise.resolve([])),
|
|
updateUser: vi.fn(),
|
|
|
|
// Association functions
|
|
getAssociationByUserId: vi.fn(() => Promise.resolve(null)),
|
|
getAssociationById: vi.fn(() => Promise.resolve(null)),
|
|
getAssociationBySourceDirectoryEntryId: vi.fn(() => Promise.resolve(null)),
|
|
createAssociation: vi.fn(() => Promise.resolve(1)),
|
|
updateAssociation: vi.fn(() => Promise.resolve()),
|
|
getAllAssociations: vi.fn(() => Promise.resolve([])),
|
|
searchAssociations: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
|
|
getAssociationCommuneCounts: vi.fn(() => Promise.resolve({
|
|
all: 0,
|
|
kourou: 0,
|
|
sinnamary: 0,
|
|
iracoubo: 0,
|
|
saint_elie: 0,
|
|
})),
|
|
toggleAssociationStatus: vi.fn(() => Promise.resolve()),
|
|
getAssociationDirectoryEntryByNormalizedEmail: vi.fn(() => Promise.resolve(null)),
|
|
getAssociationDirectoryEntryById: vi.fn(() => Promise.resolve(null)),
|
|
getAssociationDirectoryEntriesSummary: vi.fn(() => Promise.resolve({ total: 0, withEmail: 0, withoutEmail: 0, lastImportAt: null })),
|
|
listAssociationDirectoryEntriesWithStatus: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
|
|
listAssociationDirectoryMapEntries: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
|
|
getAssociationInvitationSummariesForDirectoryEntryIds: vi.fn(() => Promise.resolve({})),
|
|
listAssociationDirectoryEntries: vi.fn(() => Promise.resolve([])),
|
|
listAssociationDirectoryReviews: vi.fn(() => Promise.resolve([])),
|
|
findAssociationDirectoryMatch: vi.fn(() => Promise.resolve({ status: "none", candidates: [], reason: "no match" })),
|
|
upsertAssociationDirectoryEntry: vi.fn(() => Promise.resolve("created")),
|
|
createAssociationDirectoryEntry: vi.fn(() => Promise.resolve(1)),
|
|
updateAssociationDirectoryEntry: vi.fn(() => Promise.resolve()),
|
|
getAssociationDirectoryEntryDetails: vi.fn(() => Promise.resolve(null)),
|
|
createAssociationDirectoryReview: vi.fn(() => Promise.resolve(1)),
|
|
getAssociationDirectoryReviewById: vi.fn(() => Promise.resolve(null)),
|
|
getPendingAssociationDirectoryReviewByUserId: vi.fn(() => Promise.resolve(null)),
|
|
updateAssociationDirectoryReview: vi.fn(() => Promise.resolve()),
|
|
createAssociationInvitation: vi.fn(() => Promise.resolve(1)),
|
|
getAssociationInvitationByToken: vi.fn(() => Promise.resolve(null)),
|
|
getActiveAssociationInvitationByDirectoryEntryId: vi.fn(() => Promise.resolve(null)),
|
|
getLatestAssociationInvitationByDirectoryEntryId: vi.fn(() => Promise.resolve(null)),
|
|
revokeAssociationInvitationsByDirectoryEntryId: vi.fn(() => Promise.resolve()),
|
|
markAssociationInvitationUsed: vi.fn(() => Promise.resolve()),
|
|
|
|
// Document functions
|
|
getDocumentsByAssociationId: vi.fn(() => Promise.resolve([])),
|
|
getDocumentById: vi.fn(() => Promise.resolve(null)),
|
|
createDocument: vi.fn(() => Promise.resolve(1)),
|
|
deleteDocument: vi.fn(() => Promise.resolve()),
|
|
|
|
// Request functions
|
|
getRequestsByAssociationId: vi.fn(() => Promise.resolve([])),
|
|
getRequestById: vi.fn(() => Promise.resolve(null)),
|
|
createRequest: vi.fn(() => Promise.resolve(1)),
|
|
updateRequest: vi.fn(() => Promise.resolve()),
|
|
getAllRequests: vi.fn(() => Promise.resolve([])),
|
|
getRequestsByStatus: vi.fn(() => Promise.resolve([])),
|
|
searchRequests: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
|
|
getPendingRequests: vi.fn(() => Promise.resolve([])),
|
|
getOverdueRequests: vi.fn(() => Promise.resolve([])),
|
|
assignRequest: vi.fn(() => Promise.resolve()),
|
|
|
|
// Request history
|
|
createRequestHistory: vi.fn(() => Promise.resolve(1)),
|
|
getRequestHistoryByRequestId: vi.fn(() => Promise.resolve([])),
|
|
|
|
// Templates
|
|
getAllRequestTemplates: vi.fn(() => Promise.resolve([])),
|
|
getRequestTemplateById: vi.fn(() => Promise.resolve(null)),
|
|
getRequestTemplateByType: vi.fn(() => Promise.resolve(null)),
|
|
createRequestTemplate: vi.fn(() => Promise.resolve(1)),
|
|
updateRequestTemplate: vi.fn(() => Promise.resolve()),
|
|
|
|
getAllResponseTemplates: vi.fn(() => Promise.resolve([])),
|
|
getResponseTemplateById: vi.fn(() => Promise.resolve(null)),
|
|
createResponseTemplate: vi.fn(() => Promise.resolve(1)),
|
|
updateResponseTemplate: vi.fn(() => Promise.resolve()),
|
|
deleteResponseTemplate: vi.fn(() => Promise.resolve()),
|
|
|
|
// Audit & Notifications
|
|
createAuditLog: vi.fn(() => Promise.resolve(1)),
|
|
getAuditLogs: vi.fn(() => Promise.resolve({ data: [], total: 0 })),
|
|
createAdminNotification: vi.fn(() => Promise.resolve(1)),
|
|
getAdminNotifications: vi.fn(() => Promise.resolve([])),
|
|
markNotificationAsRead: vi.fn(() => Promise.resolve()),
|
|
markAllNotificationsAsRead: vi.fn(() => Promise.resolve()),
|
|
getUnreadNotificationCount: vi.fn(() => Promise.resolve(0)),
|
|
|
|
// Settings
|
|
getPortalSetting: vi.fn(() => Promise.resolve(null)),
|
|
setPortalSetting: vi.fn(() => Promise.resolve()),
|
|
getAllPortalSettings: vi.fn(() => Promise.resolve([])),
|
|
|
|
// Stats
|
|
getDashboardStats: vi.fn(() => Promise.resolve({
|
|
totalAssociations: 0,
|
|
activeAssociations: 0,
|
|
totalRequests: 0,
|
|
pendingRequests: 0,
|
|
validatedRequests: 0,
|
|
rejectedRequests: 0,
|
|
newAssociationsThisMonth: 0,
|
|
newRequestsThisMonth: 0,
|
|
requestsThisWeek: 0,
|
|
overdueRequests: 0,
|
|
acceptanceRate: 0,
|
|
})),
|
|
getRequestsPerMonth: vi.fn(() => Promise.resolve([])),
|
|
getAssociationsPerMonth: vi.fn(() => Promise.resolve([])),
|
|
getRequestsByTypeStats: vi.fn(() => Promise.resolve([])),
|
|
getAverageProcessingTime: vi.fn(() => Promise.resolve(null)),
|
|
}));
|
|
|
|
// Mock storage
|
|
vi.mock("./storage", () => ({
|
|
storagePut: vi.fn(() => Promise.resolve({ url: "https://example.com/file.pdf", key: "test-key" })),
|
|
storageGet: vi.fn(() => Promise.resolve({ url: "https://example.com/file.pdf", key: "test-key" })),
|
|
}));
|
|
|
|
// Mock notification
|
|
vi.mock("./_core/notification", () => ({
|
|
notifyOwner: vi.fn(() => Promise.resolve(true)),
|
|
}));
|
|
|
|
vi.mock("./mailer", () => ({
|
|
canSendOperationalEmails: vi.fn(() => true),
|
|
sendOperationalEmail: vi.fn(() => Promise.resolve({ sent: true })),
|
|
}));
|
|
|
|
vi.mock("./_core/auth", () => ({
|
|
clearSessionCookie: vi.fn(),
|
|
createSessionToken: vi.fn(() => Promise.resolve("session-token")),
|
|
loginLocalUser: vi.fn(),
|
|
registerLocalUser: vi.fn((input) => Promise.resolve({
|
|
id: 99,
|
|
openId: input.email,
|
|
email: input.email,
|
|
name: input.name,
|
|
loginMethod: "local_jwt",
|
|
role: "user",
|
|
isActive: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
lastSignedIn: new Date(),
|
|
})),
|
|
setSessionCookie: vi.fn(),
|
|
}));
|
|
|
|
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
|
|
|
function createAuthContext(role: "user" | "admin" | "directrice" | "super_admin" = "user"): TrpcContext {
|
|
const user: AuthenticatedUser = {
|
|
id: 1,
|
|
openId: "test-user-123",
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
loginMethod: "manus",
|
|
role,
|
|
canManageLogistics: false,
|
|
canSignSalle: role === "directrice" || role === "super_admin",
|
|
delegatedSalleSignerUserId: null,
|
|
salleSignatureDelegatedByUserIds: [],
|
|
isActive: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
lastSignedIn: new Date(),
|
|
};
|
|
|
|
return {
|
|
user,
|
|
req: {
|
|
protocol: "https",
|
|
headers: {},
|
|
} as TrpcContext["req"],
|
|
res: {
|
|
clearCookie: vi.fn(),
|
|
} as unknown as TrpcContext["res"],
|
|
};
|
|
}
|
|
|
|
function createUnauthContext(): TrpcContext {
|
|
return {
|
|
user: null,
|
|
req: {
|
|
protocol: "https",
|
|
headers: {},
|
|
} as TrpcContext["req"],
|
|
res: {
|
|
clearCookie: vi.fn(),
|
|
} as unknown as TrpcContext["res"],
|
|
};
|
|
}
|
|
|
|
describe("association router", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("getMyProfile returns null when no association exists", async () => {
|
|
const ctx = createAuthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
const result = await caller.association.getMyProfile();
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("exposes a protected portal directory listing", async () => {
|
|
const db = await import("./db");
|
|
const ctx = createAuthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
(db.listAssociationDirectoryEntriesWithStatus as any).mockResolvedValue({
|
|
total: 1,
|
|
data: [
|
|
{
|
|
id: 12,
|
|
nomAssociation: "Association des Savanes",
|
|
emailOfficiel: "contact@example.com",
|
|
ville: "Kourou",
|
|
importedAt: new Date(),
|
|
registered: false,
|
|
registeredAt: null,
|
|
invitationStatus: undefined,
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await caller.associationDirectory.listPortal({
|
|
commune: "kourou",
|
|
registrationStatus: "unregistered",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(result.total).toBe(1);
|
|
expect(result.data[0]?.nomAssociation).toBe("Association des Savanes");
|
|
});
|
|
|
|
it("returns a public secure invitation when the token is still valid", async () => {
|
|
const db = await import("./db");
|
|
const caller = appRouter.createCaller(createUnauthContext());
|
|
|
|
(db.getAssociationInvitationByToken as any).mockResolvedValue({
|
|
token: "invite-token",
|
|
directoryEntryId: 42,
|
|
emailOfficiel: "contact@example.com",
|
|
emailOfficielNormalise: "contact@example.com",
|
|
sentByUserId: 1,
|
|
deliveryMode: "email",
|
|
emailSent: true,
|
|
sentAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
usedAt: null,
|
|
revokedAt: null,
|
|
acceptedByUserId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
(db.getAssociationDirectoryEntryById as any).mockResolvedValue({
|
|
id: 42,
|
|
nomAssociation: "Association des Savanes",
|
|
emailOfficiel: "contact@example.com",
|
|
nomRepresentant: "Mme Test",
|
|
ville: "Kourou",
|
|
});
|
|
|
|
const result = await caller.associationInvitation.getPublic({ token: "invite-token" });
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.association.nomAssociation).toBe("Association des Savanes");
|
|
});
|
|
|
|
it("registers a user from a valid invitation and marks it as used", async () => {
|
|
const db = await import("./db");
|
|
const ctx = createUnauthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
(db.getAssociationInvitationByToken as any).mockResolvedValue({
|
|
token: "invite-token",
|
|
directoryEntryId: 42,
|
|
emailOfficiel: "contact@example.com",
|
|
emailOfficielNormalise: "contact@example.com",
|
|
sentByUserId: 1,
|
|
deliveryMode: "email",
|
|
emailSent: true,
|
|
sentAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
usedAt: null,
|
|
revokedAt: null,
|
|
acceptedByUserId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
(db.getAssociationDirectoryEntryById as any).mockResolvedValue({
|
|
id: 42,
|
|
nomAssociation: "Association Importée",
|
|
emailOfficiel: "contact@example.com",
|
|
emailOfficielNormalise: "contact@example.com",
|
|
siret: "12345678901234",
|
|
rna: "W123456789",
|
|
adresse: "1 rue des Savanes",
|
|
codePostal: "97310",
|
|
ville: "Kourou",
|
|
telephone: "0594000000",
|
|
siteWeb: null,
|
|
dateCreation: null,
|
|
objetAssociation: "Culture",
|
|
statutJuridique: "association_loi_1901",
|
|
nomRepresentant: "Mme Test",
|
|
fonctionRepresentant: "Présidente",
|
|
sourceFileName: "bordereau.xlsx",
|
|
sourceRowNumber: 2,
|
|
sourceFingerprint: "abc",
|
|
isActive: true,
|
|
importedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
(db.getAssociationBySourceDirectoryEntryId as any).mockResolvedValue(null);
|
|
(db.getAssociationByUserId as any).mockResolvedValue(null);
|
|
|
|
const result = await caller.auth.register({
|
|
name: "Mme Test",
|
|
email: "contact@example.com",
|
|
password: "motdepasse",
|
|
thematique: "culture_loisirs",
|
|
invitationToken: "invite-token",
|
|
privacyConsent: buildDataPrivacyConsent("register_account"),
|
|
});
|
|
|
|
expect(result.email).toBe("contact@example.com");
|
|
expect(db.createAssociation).toHaveBeenCalled();
|
|
expect(db.markAssociationInvitationUsed).toHaveBeenCalledWith("invite-token", 99);
|
|
});
|
|
|
|
it("getMyProfile auto-creates profile from imported directory when email matches", async () => {
|
|
const db = await import("./db");
|
|
const ctx = createAuthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
(db.getAssociationDirectoryEntryByNormalizedEmail as any).mockResolvedValue({
|
|
id: 42,
|
|
nomAssociation: "Association Importée",
|
|
emailOfficiel: "test@example.com",
|
|
emailOfficielNormalise: "test@example.com",
|
|
siret: "12345678901234",
|
|
rna: "W123456789",
|
|
adresse: "1 rue des Savanes",
|
|
codePostal: "97310",
|
|
ville: "Kourou",
|
|
telephone: "0594000000",
|
|
siteWeb: null,
|
|
dateCreation: null,
|
|
objetAssociation: "Culture",
|
|
statutJuridique: "association_loi_1901",
|
|
nomRepresentant: "Mme Test",
|
|
fonctionRepresentant: "Présidente",
|
|
sourceFileName: "bordereau.xlsx",
|
|
sourceRowNumber: 2,
|
|
sourceFingerprint: "abc",
|
|
isActive: true,
|
|
importedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
(db.getAssociationById as any).mockResolvedValue({
|
|
id: 1,
|
|
userId: 1,
|
|
sourceDirectoryEntryId: 42,
|
|
nomAssociation: "Association Importée",
|
|
siret: "12345678901234",
|
|
rna: "W123456789",
|
|
adresse: "1 rue des Savanes",
|
|
codePostal: "97310",
|
|
ville: "Kourou",
|
|
telephone: "0594000000",
|
|
emailContact: "test@example.com",
|
|
siteWeb: null,
|
|
dateCreation: null,
|
|
objetAssociation: "Culture",
|
|
statutJuridique: "association_loi_1901",
|
|
nomRepresentant: "Mme Test",
|
|
fonctionRepresentant: "Présidente",
|
|
profileComplete: true,
|
|
isActive: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const result = await caller.association.getMyProfile();
|
|
|
|
expect(db.createAssociation).toHaveBeenCalled();
|
|
expect(result?.sourceDirectoryEntryId).toBe(42);
|
|
expect(result?.nomAssociation).toBe("Association Importée");
|
|
});
|
|
|
|
it("upsertProfile requires authentication", async () => {
|
|
const ctx = createUnauthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
await expect(
|
|
caller.association.upsertProfile({
|
|
nomAssociation: "Test Association",
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("upsertProfile creates association for authenticated user", async () => {
|
|
const ctx = createAuthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
const result = await caller.association.upsertProfile({
|
|
nomAssociation: "Test Association",
|
|
siret: "12345678901234",
|
|
adresse: "123 rue Test",
|
|
codePostal: "75001",
|
|
ville: "Paris",
|
|
gouvernance: {
|
|
representantLegal: {
|
|
prenom: "Jean",
|
|
nom: "Dupont",
|
|
fonction: "president",
|
|
email: "",
|
|
telephone: "",
|
|
},
|
|
membres: [],
|
|
},
|
|
privacyConsent: buildDataPrivacyConsent("save_profile"),
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result).toHaveProperty('id');
|
|
expect(result.updated).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("document router", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("getMyDocuments requires authentication", async () => {
|
|
const ctx = createUnauthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
await expect(caller.document.getMyDocuments()).rejects.toThrow();
|
|
});
|
|
|
|
it("getMyDocuments returns empty array when no documents exist", async () => {
|
|
const ctx = createAuthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
const result = await caller.document.getMyDocuments();
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("request router", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("getMyRequests requires authentication", async () => {
|
|
const ctx = createUnauthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
await expect(caller.request.getMyRequests()).rejects.toThrow();
|
|
});
|
|
|
|
it("getMyRequests returns empty array when no requests exist", async () => {
|
|
const ctx = createAuthContext();
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
const result = await caller.request.getMyRequests();
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("stats router", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("getDashboard requires admin role", async () => {
|
|
const ctx = createAuthContext("user");
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
await expect(caller.stats.getDashboard()).rejects.toThrow();
|
|
});
|
|
|
|
it("getDashboard returns stats for admin", async () => {
|
|
const ctx = createAuthContext("admin");
|
|
const caller = appRouter.createCaller(ctx);
|
|
|
|
const result = await caller.stats.getDashboard();
|
|
expect(result).toBeDefined();
|
|
expect(result.totalAssociations).toBe(0);
|
|
expect(result.totalRequests).toBe(0);
|
|
});
|
|
});
|