Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user