sendEmail caught transport failures internally and resolved to false or undefined, so every caller's .catch() was dead code and send failures were silently suppressed. Password recovery in particular reported "email sent successfully" while the user received nothing. sendEmail now throws an AppError carrying the underlying cause, and returns Promise<string> instead of Promise<string | false | undefined>. Call sites updated for the new contract: - UserService.registerUser: the existing .catch() now actually fires - UserService.requestRecovery: the failure reaches the client instead of a misleading success response - InviteService and the test-email endpoint: keep their user-facing messages and attach the SMTP cause - EmailProvider: catches and returns false, because NotificationsService fans providers out through Promise.all and a rejection there would abort the other channels' alerts Closes #3757
157 lines
6.3 KiB
TypeScript
157 lines
6.3 KiB
TypeScript
import { describe, expect, it, jest } from "@jest/globals";
|
|
import { InviteService } from "../../../src/domain/invites/invite.service.ts";
|
|
import type { Invite } from "../../../src/domain/invites/invite.type.ts";
|
|
import type { UserRole } from "../../../src/domain/users/user.type.ts";
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
const makeInvite = (overrides?: Partial<Invite>): Invite => ({
|
|
id: "inv-1",
|
|
email: "invited@example.com",
|
|
teamId: "team-1",
|
|
role: ["user"] as UserRole[],
|
|
token: "invite-token-123",
|
|
expiry: "2026-12-31T00:00:00Z",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
});
|
|
|
|
const createService = (overrides?: Record<string, unknown>) => {
|
|
const invitesRepository = {
|
|
create: jest.fn().mockResolvedValue(makeInvite()),
|
|
findByToken: jest.fn().mockResolvedValue(makeInvite()),
|
|
};
|
|
const settingsService = {
|
|
getSettings: jest.fn().mockReturnValue({ clientHost: "http://localhost:5173" }),
|
|
};
|
|
const emailService = {
|
|
buildEmail: jest.fn().mockResolvedValue("<html>Invite</html>"),
|
|
sendEmail: jest.fn().mockResolvedValue("msg-id-123"),
|
|
};
|
|
|
|
const defaults = { invitesRepository, settingsService, emailService, ...overrides };
|
|
|
|
const service = new InviteService(defaults as any);
|
|
return { service, ...defaults };
|
|
};
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("InviteService", () => {
|
|
// ── getInviteToken ──────────────────────────────────────────────────────
|
|
|
|
describe("getInviteToken", () => {
|
|
it("creates an invite with teamId assigned", async () => {
|
|
const { service, invitesRepository } = createService();
|
|
const invite: Partial<Invite> = { email: "new@example.com", role: ["user"] };
|
|
|
|
const result = await service.getInviteToken({ invite, teamId: "team-1", userRoles: ["superadmin"] });
|
|
|
|
expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com", teamId: "team-1" }));
|
|
expect(result).toEqual(makeInvite());
|
|
});
|
|
|
|
it("allows creation when role is undefined (defaults to empty)", async () => {
|
|
const { service } = createService();
|
|
|
|
const result = await service.getInviteToken({ invite: { email: "new@example.com" }, teamId: "team-1", userRoles: ["user"] });
|
|
|
|
expect(result).toEqual(makeInvite());
|
|
});
|
|
|
|
it("throws 403 when actor cannot manage the target role", async () => {
|
|
const { service } = createService();
|
|
|
|
await expect(service.getInviteToken({ invite: { role: ["superadmin"] }, teamId: "team-1", userRoles: ["admin"] })).rejects.toThrow(
|
|
"You do not have permission to create this invite"
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── sendInviteEmail ─────────────────────────────────────────────────────
|
|
|
|
describe("sendInviteEmail", () => {
|
|
it("creates invite, builds email, and sends it", async () => {
|
|
const { service, invitesRepository, emailService, settingsService } = createService();
|
|
|
|
await service.sendInviteEmail({
|
|
invite: { email: "new@example.com", role: ["user"] },
|
|
firstName: "Test",
|
|
userRoles: ["superadmin"],
|
|
});
|
|
|
|
expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com" }));
|
|
expect(settingsService.getSettings).toHaveBeenCalled();
|
|
expect(emailService.buildEmail).toHaveBeenCalledWith("employeeActivationTemplate", {
|
|
name: "Test",
|
|
link: "http://localhost:5173/register/invite-token-123",
|
|
});
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith("new@example.com", "Welcome to Uptime Monitor", "<html>Invite</html>");
|
|
});
|
|
|
|
it("throws 400 when invite email is missing", async () => {
|
|
const { service } = createService();
|
|
|
|
await expect(service.sendInviteEmail({ invite: {}, firstName: "Test", userRoles: ["superadmin"] })).rejects.toThrow(
|
|
"Invite email is required to send an invite"
|
|
);
|
|
});
|
|
|
|
it("throws 403 when actor cannot manage the target role", async () => {
|
|
const { service } = createService();
|
|
|
|
await expect(
|
|
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["superadmin"] }, firstName: "Test", userRoles: ["admin"] })
|
|
).rejects.toThrow("You do not have permission to create this invite");
|
|
});
|
|
|
|
it("allows sending when role is undefined (defaults to empty)", async () => {
|
|
const { service, emailService } = createService();
|
|
|
|
await service.sendInviteEmail({ invite: { email: "new@example.com" }, firstName: "Test", userRoles: ["user"] });
|
|
|
|
expect(emailService.sendEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws 500 when buildEmail returns falsy", async () => {
|
|
const { service } = createService({
|
|
emailService: {
|
|
buildEmail: jest.fn().mockResolvedValue(null),
|
|
sendEmail: jest.fn(),
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] })
|
|
).rejects.toThrow("Failed to build invite e-mail");
|
|
});
|
|
|
|
it("throws 500 when sendEmail rejects", async () => {
|
|
const { service } = createService({
|
|
emailService: {
|
|
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
|
|
sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")),
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] })
|
|
).rejects.toThrow("Failed to send invite e-mail");
|
|
});
|
|
});
|
|
|
|
// ── verifyInviteToken ───────────────────────────────────────────────────
|
|
|
|
describe("verifyInviteToken", () => {
|
|
it("delegates to repository", async () => {
|
|
const { service, invitesRepository } = createService();
|
|
|
|
const result = await service.verifyInviteToken({ inviteToken: "invite-token-123" });
|
|
|
|
expect(invitesRepository.findByToken).toHaveBeenCalledWith("invite-token-123");
|
|
expect(result).toEqual(makeInvite());
|
|
});
|
|
});
|
|
});
|