230 lines
9.8 KiB
TypeScript
230 lines
9.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { prisma } from "@/services/scheduling/__tests__/fixtures";
|
|
import { InstallationStatus } from "@/generated/prisma/enums";
|
|
import { installApplication } from "../installer.service";
|
|
import type { InstallInput } from "../schema";
|
|
|
|
/**
|
|
* Exercises the real transactional installer against a real MySQL
|
|
* database — see installer.service.ts's own header for why the
|
|
* ApplicationInstallation singleton row is the concurrency lock. These
|
|
* tests own that row exclusively: any pre-existing installation state in
|
|
* this database is removed in `beforeEach` and never restored, since a
|
|
* test database is expected to be disposable (see
|
|
* docs/phase-8-summary.md's testing section) — do not point this at a
|
|
* real deployment's database.
|
|
*/
|
|
|
|
const INSTALLATION_TOKEN = "integration-test-install-token";
|
|
|
|
function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
|
|
return {
|
|
installationToken: INSTALLATION_TOKEN,
|
|
administrator: {
|
|
firstName: "Test",
|
|
lastName: "Owner",
|
|
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
|
|
phone: "",
|
|
password: "correct-horse-battery",
|
|
confirmPassword: "correct-horse-battery",
|
|
},
|
|
business: {
|
|
name: "Integration Test Barbershop",
|
|
shortName: "",
|
|
description: "",
|
|
email: "",
|
|
phone: "",
|
|
website: "",
|
|
},
|
|
location: {
|
|
addressLine1: "1 Test Street",
|
|
addressLine2: "",
|
|
city: "Testville",
|
|
region: "",
|
|
postalCode: "T1 1TT",
|
|
country: "GB",
|
|
timezone: "Europe/London",
|
|
currency: "GBP",
|
|
locale: "en-GB",
|
|
},
|
|
hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({
|
|
dayOfWeek,
|
|
isClosed: dayOfWeek === "SUNDAY",
|
|
opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540,
|
|
closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080,
|
|
})),
|
|
bookingRules: {
|
|
allowGuestBooking: false,
|
|
minNoticeMinutes: 120,
|
|
maxAdvanceDays: 60,
|
|
cancellationWindowHours: 24,
|
|
rescheduleWindowHours: 24,
|
|
slotIntervalMinutes: 30,
|
|
bufferBeforeMinutes: 0,
|
|
bufferAfterMinutes: 5,
|
|
maxAppointmentsPerDay: null,
|
|
maxAppointmentsPerStaffPerDay: null,
|
|
customerCanChooseStaff: true,
|
|
requireApproval: false,
|
|
},
|
|
branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" },
|
|
starterServices: [
|
|
{ categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true },
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function resetInstallationSingleton(): Promise<void> {
|
|
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
|
|
}
|
|
|
|
async function cleanupInstalledBusiness(businessId: string | undefined): Promise<void> {
|
|
if (!businessId) return;
|
|
const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } });
|
|
const userIds = staff.map((s) => s.userId);
|
|
await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } });
|
|
await prisma.staffProfile.deleteMany({ where: { businessId } });
|
|
await prisma.service.deleteMany({ where: { businessId } });
|
|
await prisma.serviceCategory.deleteMany({ where: { businessId } });
|
|
await prisma.rolePermission.deleteMany({ where: { role: { businessId } } });
|
|
await prisma.role.deleteMany({ where: { businessId } });
|
|
await prisma.auditLog.deleteMany({ where: { entityId: businessId } });
|
|
await prisma.loyaltyProgramme.deleteMany({ where: { businessId } });
|
|
await prisma.bookingRule.deleteMany({ where: { businessId } });
|
|
await prisma.businessSettings.deleteMany({ where: { businessId } });
|
|
await prisma.businessHours.deleteMany({ where: { businessId } });
|
|
await prisma.businessLocation.deleteMany({ where: { businessId } });
|
|
await prisma.business.deleteMany({ where: { id: businessId } });
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await resetInstallationSingleton();
|
|
}
|
|
|
|
describe("installApplication (integration)", () => {
|
|
let createdBusinessId: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
await resetInstallationSingleton();
|
|
createdBusinessId = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await cleanupInstalledBusiness(createdBusinessId);
|
|
});
|
|
|
|
it("rejects an incorrect installation token without creating anything", async () => {
|
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
|
try {
|
|
const result = await installApplication(prisma, {
|
|
input: buildInput({ installationToken: "wrong-token" }),
|
|
installedVersion: "test",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) expect(result.code).toBe("INVALID_TOKEN");
|
|
|
|
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
|
|
expect(business).toBeNull();
|
|
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
|
expect(installation).toBeNull();
|
|
} finally {
|
|
process.env.INSTALLATION_TOKEN = originalToken;
|
|
}
|
|
});
|
|
|
|
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
|
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
|
try {
|
|
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
|
expect(result.success).toBe(true);
|
|
if (!result.success) return;
|
|
createdBusinessId = result.businessId;
|
|
|
|
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
|
expect(installation?.status).toBe(InstallationStatus.INSTALLED);
|
|
expect(installation?.primaryBusinessId).toBe(result.businessId);
|
|
expect(installation?.initialAdminUserId).toBe(result.userId);
|
|
|
|
const business = await prisma.business.findUnique({
|
|
where: { id: result.businessId },
|
|
include: { bookingRule: true, locations: true, hours: true, loyaltyProgramme: true },
|
|
});
|
|
expect(business?.name).toBe("Integration Test Barbershop");
|
|
// Guest booking off by default, per the brief — confirmed end to end here.
|
|
expect(business?.bookingRule?.allowGuestBooking).toBe(false);
|
|
expect(business?.locations).toHaveLength(1);
|
|
expect(business?.hours).toHaveLength(7);
|
|
expect(business?.loyaltyProgramme?.isEnabled).toBe(false);
|
|
|
|
const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } });
|
|
const ownerRole = roles.find((r) => r.name === "Owner");
|
|
const barberRole = roles.find((r) => r.name === "Barber");
|
|
expect(ownerRole).toBeDefined();
|
|
expect(barberRole).toBeDefined();
|
|
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
|
|
|
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
|
|
expect(staffProfile?.roleId).toBe(ownerRole!.id);
|
|
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
|
|
expect(staffProfile?.services).toHaveLength(1);
|
|
|
|
const services = await prisma.service.findMany({ where: { businessId: result.businessId } });
|
|
expect(services).toHaveLength(1);
|
|
expect(services[0]?.name).toBe("Classic Cut");
|
|
|
|
const auditLog = await prisma.auditLog.findFirst({ where: { entityId: result.businessId, action: "APPLICATION_INSTALLED" } });
|
|
expect(auditLog).toBeTruthy();
|
|
} finally {
|
|
process.env.INSTALLATION_TOKEN = originalToken;
|
|
}
|
|
});
|
|
|
|
it("refuses to install a second time once already installed", async () => {
|
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
|
try {
|
|
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
|
expect(first.success).toBe(true);
|
|
if (first.success) createdBusinessId = first.businessId;
|
|
|
|
const second = await installApplication(prisma, {
|
|
input: buildInput({
|
|
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
|
|
}),
|
|
installedVersion: "test",
|
|
});
|
|
expect(second.success).toBe(false);
|
|
if (!second.success) expect(second.code).toBe("ALREADY_INSTALLED");
|
|
} finally {
|
|
process.env.INSTALLATION_TOKEN = originalToken;
|
|
}
|
|
});
|
|
|
|
it("prevents two concurrent installation attempts from both succeeding", async () => {
|
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
|
try {
|
|
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
|
|
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
|
|
|
|
const [resultA, resultB] = await Promise.all([
|
|
installApplication(prisma, { input: inputA, installedVersion: "test" }),
|
|
installApplication(prisma, { input: inputB, installedVersion: "test" }),
|
|
]);
|
|
|
|
const successes = [resultA, resultB].filter((r) => r.success);
|
|
expect(successes).toHaveLength(1);
|
|
const winner = successes[0];
|
|
if (winner?.success) createdBusinessId = winner.businessId;
|
|
|
|
const businessCount = await prisma.business.count({
|
|
where: { name: "Integration Test Barbershop" },
|
|
});
|
|
expect(businessCount).toBe(1);
|
|
} finally {
|
|
process.env.INSTALLATION_TOKEN = originalToken;
|
|
}
|
|
});
|
|
});
|