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 { 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 { await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } }); } async function cleanupInstalledBusiness(businessId: string | undefined): Promise { 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; } }); });