361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createRegistrationForm, createUserAndTenant, getRegistrationFormData } from "./authService";
|
|
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
|
import { createRegistration, getRegistrationByEmail, updateRegistration } from "~/utils/db/registration.db.server";
|
|
import { createTenant, createTenantUser } from "~/utils/db/tenants.db.server";
|
|
import { getAllRoles } from "~/utils/db/permissions/roles.db.server";
|
|
import { register } from "~/utils/db/users.db.server";
|
|
import { queueTransactionalEmail } from "~/modules/emails/services/TransactionalEmailQueue.server";
|
|
import EmailTemplates from "~/modules/emails/utils/EmailTemplates";
|
|
import { TenantTypesApi } from "~/utils/api/.server/TenantTypesApi";
|
|
import EventsService from "~/modules/events/services/.server/EventsService";
|
|
import { sendEmail } from "~/modules/emails/services/EmailService";
|
|
|
|
vi.mock("~/utils/db/appConfiguration.db.server", () => ({
|
|
getAppConfiguration: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/registration.db.server", () => ({
|
|
createRegistration: vi.fn(),
|
|
getRegistrationByEmail: vi.fn(),
|
|
updateRegistration: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/tenants.db.server", () => ({
|
|
createTenant: vi.fn(),
|
|
createTenantUser: vi.fn(),
|
|
getTenantBySlug: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/permissions/roles.db.server", () => ({
|
|
getAllRoles: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/users.db.server", () => ({
|
|
getUserByEmail: vi.fn(),
|
|
register: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/modules/emails/services/TransactionalEmailQueue.server", () => ({
|
|
queueTransactionalEmail: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/modules/emails/services/EmailService", () => ({
|
|
sendEmail: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/url.server", () => ({
|
|
getAbsoluteUrl: vi.fn((_request: Request, path: string) => `https://example.com${path}`),
|
|
}));
|
|
|
|
vi.mock("~/utils/session.server", () => ({
|
|
getUserInfo: vi.fn(async () => ({ lng: "en" })),
|
|
}));
|
|
|
|
vi.mock("crypto", () => ({
|
|
default: {
|
|
randomBytes: vi.fn(() => ({ toString: vi.fn(() => "registration-token") })),
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/modules/emails/utils/EmailTemplates", () => ({
|
|
default: {
|
|
VERIFICATION_EMAIL: {
|
|
parse: vi.fn(({ data }) => ({
|
|
subject: "Verify your email",
|
|
body: `<p>Verify at ${data.action_url} for ${data.name}</p>`,
|
|
})),
|
|
},
|
|
WELCOME_EMAIL: {
|
|
parse: vi.fn(({ data }) => ({
|
|
subject: "Welcome",
|
|
body: `<p>Welcome at ${data.action_url} for ${data.name}</p>`,
|
|
})),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/api/.server/TenantTypesApi", () => ({
|
|
TenantTypesApi: {
|
|
setTenantTypes: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/modules/events/services/.server/EventsService", () => ({
|
|
default: {
|
|
create: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/stripe.server", () => ({
|
|
createStripeCustomer: vi.fn(),
|
|
isStripeConfigured: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({
|
|
default: {
|
|
log: vi.fn(async () => undefined),
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {},
|
|
}));
|
|
|
|
vi.mock("~/locale/i18next.server", () => ({
|
|
getTranslations: vi.fn(async () => ({ t: (key: string) => key })),
|
|
remixI18Next: {
|
|
getFixedT: vi.fn(async () => (key: string) => key),
|
|
getLocale: vi.fn(async () => "de"),
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/locale/mail-translator.server", () => ({
|
|
getMailTranslator: vi.fn(async () => ({ t: (key: string) => key, locale: "de" })),
|
|
getMailTranslatorFromRequest: vi.fn(async () => ({ t: (key: string) => key, locale: "de" })),
|
|
resolveMailLocale: vi.fn((locale: string | null | undefined) => locale ?? "de"),
|
|
}));
|
|
|
|
const getAppConfigurationMock = vi.mocked(getAppConfiguration);
|
|
const getRegistrationByEmailMock = vi.mocked(getRegistrationByEmail);
|
|
const updateRegistrationMock = vi.mocked(updateRegistration);
|
|
const createRegistrationMock = vi.mocked(createRegistration);
|
|
const queueTransactionalEmailMock = vi.mocked(queueTransactionalEmail);
|
|
const verificationParseMock = vi.mocked(EmailTemplates.VERIFICATION_EMAIL.parse);
|
|
const welcomeParseMock = vi.mocked(EmailTemplates.WELCOME_EMAIL.parse);
|
|
const createTenantMock = vi.mocked(createTenant);
|
|
const registerMock = vi.mocked(register);
|
|
const getAllRolesMock = vi.mocked(getAllRoles);
|
|
const createTenantUserMock = vi.mocked(createTenantUser);
|
|
const setTenantTypesMock = vi.mocked(TenantTypesApi.setTenantTypes);
|
|
const eventsCreateMock = vi.mocked(EventsService.create);
|
|
const sendEmailMock = vi.mocked(sendEmail);
|
|
|
|
const request = new Request("https://example.com/register");
|
|
const appConfiguration = {
|
|
app: { name: "Example" },
|
|
email: { fromName: "Support", supportEmail: "support@example.com" },
|
|
} as Awaited<ReturnType<typeof getAppConfiguration>>;
|
|
|
|
describe("auth service transactional registration emails", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getAppConfigurationMock.mockResolvedValue(appConfiguration);
|
|
queueTransactionalEmailMock.mockResolvedValue({ enqueued: true, jobId: "job_1" });
|
|
});
|
|
|
|
it("recreates pending registration tokens and enqueues verification email", async () => {
|
|
getRegistrationByEmailMock.mockResolvedValue({ id: "registration_1", createdTenantId: null } as Awaited<ReturnType<typeof getRegistrationByEmail>>);
|
|
updateRegistrationMock.mockResolvedValue({ id: "registration_1" } as Awaited<ReturnType<typeof updateRegistration>>);
|
|
|
|
await createRegistrationForm({ request, email: "ada@example.com", ipAddress: "127.0.0.1", firstName: "Ada", company: "Ada Co", recreateToken: true });
|
|
|
|
expect(updateRegistrationMock).toHaveBeenCalledWith("registration_1", {
|
|
firstName: "Ada",
|
|
lastName: undefined,
|
|
company: "Ada Co",
|
|
token: "registration-token",
|
|
leadConversionLeadId: null,
|
|
leadConversionToken: null,
|
|
leadConversionPlanId: null,
|
|
});
|
|
expect(verificationParseMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
appConfiguration,
|
|
data: {
|
|
action_url: "https://example.com/verify/registration-token",
|
|
name: "Ada",
|
|
},
|
|
t: expect.any(Function),
|
|
locale: expect.any(String),
|
|
})
|
|
);
|
|
expect(queueTransactionalEmailMock).toHaveBeenCalledWith({
|
|
to: "ada@example.com",
|
|
alias: "email-verification",
|
|
subject: "Verify your email",
|
|
body: "<p>Verify at https://example.com/verify/registration-token for Ada</p>",
|
|
data: {
|
|
action_url: "https://example.com/verify/registration-token",
|
|
name: "Ada",
|
|
},
|
|
priorityOverride: "critical",
|
|
});
|
|
expect(queueTransactionalEmailMock.mock.invocationCallOrder[0]).toBeGreaterThan(updateRegistrationMock.mock.invocationCallOrder[0]);
|
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refreshes persisted lead conversion context when recreating pending registration tokens", async () => {
|
|
getRegistrationByEmailMock.mockResolvedValue({ id: "registration_1", createdTenantId: null } as Awaited<ReturnType<typeof getRegistrationByEmail>>);
|
|
updateRegistrationMock.mockResolvedValue({ id: "registration_1" } as Awaited<ReturnType<typeof updateRegistration>>);
|
|
|
|
await createRegistrationForm({
|
|
request,
|
|
email: "lead@example.com",
|
|
ipAddress: "127.0.0.1",
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
company: "Ada Co",
|
|
recreateToken: true,
|
|
leadId: "lead_2",
|
|
leadToken: "signed-token-2",
|
|
leadPlanId: "plan_scale",
|
|
});
|
|
|
|
expect(updateRegistrationMock).toHaveBeenCalledWith("registration_1", {
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
company: "Ada Co",
|
|
token: "registration-token",
|
|
leadConversionLeadId: "lead_2",
|
|
leadConversionToken: "signed-token-2",
|
|
leadConversionPlanId: "plan_scale",
|
|
});
|
|
expect(queueTransactionalEmailMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
to: "lead@example.com",
|
|
alias: "email-verification",
|
|
priorityOverride: "critical",
|
|
})
|
|
);
|
|
expect(queueTransactionalEmailMock.mock.invocationCallOrder[0]).toBeGreaterThan(updateRegistrationMock.mock.invocationCallOrder[0]);
|
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates new registration records and enqueues verification email", async () => {
|
|
getRegistrationByEmailMock.mockResolvedValue(null);
|
|
createRegistrationMock.mockResolvedValue({ id: "registration_1" } as Awaited<ReturnType<typeof createRegistration>>);
|
|
|
|
await createRegistrationForm({ request, email: "ada@example.com", ipAddress: "127.0.0.1", firstName: "Ada", lastName: "Lovelace", company: "Ada Co", slug: "ada-co" });
|
|
|
|
expect(createRegistrationMock).toHaveBeenCalledWith({
|
|
email: "ada@example.com",
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
company: "Ada Co",
|
|
token: "registration-token",
|
|
selectedSubscriptionPriceId: null,
|
|
leadConversionLeadId: null,
|
|
leadConversionToken: null,
|
|
leadConversionPlanId: null,
|
|
ipAddress: "127.0.0.1",
|
|
slug: "ada-co",
|
|
});
|
|
expect(queueTransactionalEmailMock).toHaveBeenCalledWith({
|
|
to: "ada@example.com",
|
|
alias: "email-verification",
|
|
subject: "Verify your email",
|
|
body: "<p>Verify at https://example.com/verify/registration-token for Ada</p>",
|
|
data: {
|
|
action_url: "https://example.com/verify/registration-token",
|
|
name: "Ada",
|
|
},
|
|
priorityOverride: "critical",
|
|
});
|
|
expect(queueTransactionalEmailMock.mock.invocationCallOrder[0]).toBeGreaterThan(createRegistrationMock.mock.invocationCallOrder[0]);
|
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists signed lead conversion context for email verification registrations", async () => {
|
|
getRegistrationByEmailMock.mockResolvedValue(null);
|
|
createRegistrationMock.mockResolvedValue({ id: "registration_1" } as Awaited<ReturnType<typeof createRegistration>>);
|
|
|
|
await createRegistrationForm({
|
|
request,
|
|
email: "lead@example.com",
|
|
ipAddress: "127.0.0.1",
|
|
firstName: "Ada",
|
|
company: "Ada Co",
|
|
leadId: "lead_1",
|
|
leadToken: "signed-token",
|
|
leadPlanId: "plan_pro",
|
|
});
|
|
|
|
expect(createRegistrationMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
email: "lead@example.com",
|
|
leadConversionLeadId: "lead_1",
|
|
leadConversionToken: "signed-token",
|
|
leadConversionPlanId: "plan_pro",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("parses signed lead conversion fields from registration posts", async () => {
|
|
const form = new URLSearchParams({
|
|
email: "Lead@Example.com ",
|
|
password: "Password123!",
|
|
company: "Ada Co",
|
|
leadId: "lead_1",
|
|
leadToken: "signed-token",
|
|
leadPlanId: "plan_pro",
|
|
});
|
|
|
|
await expect(
|
|
getRegistrationFormData(
|
|
new Request("https://example.com/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: form,
|
|
})
|
|
)
|
|
).resolves.toMatchObject({
|
|
email: "lead@example.com",
|
|
company: "Ada Co",
|
|
leadId: "lead_1",
|
|
leadToken: "signed-token",
|
|
leadPlanId: "plan_pro",
|
|
});
|
|
});
|
|
|
|
it("creates new accounts before enqueueing welcome email with account metadata", async () => {
|
|
createTenantMock.mockResolvedValue({ id: "tenant_1", name: "Ada Co", slug: "ada-co" } as Awaited<ReturnType<typeof createTenant>>);
|
|
registerMock.mockResolvedValue({ id: "user_1", email: "ada@example.com" } as Awaited<ReturnType<typeof register>>);
|
|
getAllRolesMock.mockResolvedValue([{ id: "role_1" }] as Awaited<ReturnType<typeof getAllRoles>>);
|
|
createTenantUserMock.mockResolvedValue({ id: "tenant_user_1" } as Awaited<ReturnType<typeof createTenantUser>>);
|
|
setTenantTypesMock.mockResolvedValue(undefined);
|
|
eventsCreateMock.mockResolvedValue({} as Awaited<ReturnType<typeof EventsService.create>>);
|
|
|
|
const result = await createUserAndTenant({ request, email: "ada@example.com", password: "password", company: "Ada Co", firstName: "Ada", lastName: "Lovelace", locale: "en", slug: "ada-co" });
|
|
|
|
expect(result).toEqual({ user: { id: "user_1", email: "ada@example.com" }, tenant: { id: "tenant_1", name: "Ada Co", slug: "ada-co" } });
|
|
expect(queueTransactionalEmailMock).toHaveBeenCalledWith({
|
|
to: "ada@example.com",
|
|
alias: "welcome",
|
|
tenantId: "tenant_1",
|
|
userId: "user_1",
|
|
subject: "Welcome",
|
|
body: "<p>Welcome at https://example.com/login for Ada</p>",
|
|
data: {
|
|
action_url: "https://example.com/login",
|
|
name: "Ada",
|
|
},
|
|
});
|
|
expect(queueTransactionalEmailMock.mock.invocationCallOrder[0]).toBeGreaterThan(createTenantUserMock.mock.invocationCallOrder[0]);
|
|
expect(queueTransactionalEmailMock.mock.invocationCallOrder[0]).toBeGreaterThan(setTenantTypesMock.mock.invocationCallOrder[0]);
|
|
expect(welcomeParseMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
appConfiguration,
|
|
data: {
|
|
action_url: "https://example.com/login",
|
|
name: "Ada",
|
|
},
|
|
t: expect.any(Function),
|
|
locale: expect.any(String),
|
|
})
|
|
);
|
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws for already-created registrations without enqueueing", async () => {
|
|
getRegistrationByEmailMock.mockResolvedValue({ id: "registration_1", createdTenantId: "tenant_1" } as Awaited<ReturnType<typeof getRegistrationByEmail>>);
|
|
|
|
await expect(createRegistrationForm({ request, email: "ada@example.com", ipAddress: "127.0.0.1", recreateToken: true })).rejects.toThrow("api.errors.userAlreadyRegistered");
|
|
|
|
expect(updateRegistrationMock).not.toHaveBeenCalled();
|
|
expect(createRegistrationMock).not.toHaveBeenCalled();
|
|
expect(queueTransactionalEmailMock).not.toHaveBeenCalled();
|
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|