From 6c63ac3c80111e854c333a264fd287ee0471b08b Mon Sep 17 00:00:00 2001 From: Hendrik Date: Sun, 12 Oct 2025 15:08:32 +0200 Subject: [PATCH] Fetch tenant (#101) * Fetch tenant * Update src/routes/api/tenants/[id]/+server.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/routes/api/tenants/[id]/+server.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Column rename * Fixed tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/server/db/tenant-schema.ts | 4 - .../__tests__/tenant-admin-service.test.ts | 101 ++++++++++++++++-- .../server/services/tenant-admin-service.ts | 10 +- src/routes/api/tenants/[id]/+server.ts | 13 ++- tenant-migrations/meta/0003_snapshot.json | 90 ++++------------ tenant-migrations/meta/_journal.json | 2 +- 6 files changed, 130 insertions(+), 90 deletions(-) diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index c4242d3..318d2a6 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -1,5 +1,4 @@ import type { InferSelectModel } from "drizzle-orm"; -import { eq } from "drizzle-orm"; import { pgTable, boolean, @@ -11,12 +10,9 @@ import { integer, json, timestamp, - uniqueIndex, varchar, } from "drizzle-orm/pg-core"; -import { user } from "./central-schema"; - /** * Database enums for tenant-specific entities */ diff --git a/src/lib/server/services/__tests__/tenant-admin-service.test.ts b/src/lib/server/services/__tests__/tenant-admin-service.test.ts index 2662b43..7e50f94 100644 --- a/src/lib/server/services/__tests__/tenant-admin-service.test.ts +++ b/src/lib/server/services/__tests__/tenant-admin-service.test.ts @@ -43,6 +43,26 @@ describe("TenantAdminService", () => { let mockTenantConfig: any; let mockTenantMigrationService: any; + // Helper function to create mock select builder for getTenantById + const createMockSelectBuilder = (tenantData: any[] = []) => ({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(tenantData), + }); + + // Helper function to create mock tenant data + const createMockTenant = (tenantId: string) => ({ + id: tenantId, + shortName: "test-clinic", + longName: "Test Clinic", + descriptions: { en: "A test clinic" }, + languages: ["en"], + databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic", + setupState: "NEW", + createdAt: new Date(), + updatedAt: new Date(), + }); + beforeEach(async () => { vi.clearAllMocks(); @@ -162,24 +182,56 @@ describe("TenantAdminService", () => { describe("getTenantById", () => { it("should get tenant by ID and initialize configuration", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); + const mockConfig = { setConfig: vi.fn(), getConfig: vi.fn(), }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); const result = await TenantAdminService.getTenantById(tenantId); expect(mockTenantConfig.create).toHaveBeenCalledWith(tenantId); + expect(mockCentralDb.select).toHaveBeenCalled(); + expect(mockSelectBuilder.from).toHaveBeenCalled(); + expect(mockSelectBuilder.where).toHaveBeenCalled(); + expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1); expect(result).toBeInstanceOf(TenantAdminService); expect(result.tenantId).toBe(tenantId); }); + + it("should throw NotFoundError when tenant does not exist", async () => { + const tenantId = "non-existent"; + const mockConfig = { + setConfig: vi.fn(), + getConfig: vi.fn(), + }; + + const mockSelectBuilder = createMockSelectBuilder([]); // Empty array = no tenant found + + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + await expect(TenantAdminService.getTenantById(tenantId)).rejects.toThrow( + "Tenant with ID non-existent not found", + ); + + expect(mockCentralDb.select).toHaveBeenCalled(); + expect(mockSelectBuilder.from).toHaveBeenCalled(); + expect(mockSelectBuilder.where).toHaveBeenCalled(); + expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1); + }); }); describe("getDb", () => { it("should return database connection", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const mockConfig = { setConfig: vi.fn() }; const mockTenantDb = { select: vi.fn(), @@ -187,7 +239,10 @@ describe("TenantAdminService", () => { update: vi.fn(), }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); mockGetTenantDb.mockResolvedValue(mockTenantDb); const service = await TenantAdminService.getTenantById(tenantId); @@ -199,10 +254,14 @@ describe("TenantAdminService", () => { it("should cache database connection", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const mockConfig = { setConfig: vi.fn() }; const mockTenantDb = { select: vi.fn() }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); mockGetTenantDb.mockResolvedValue(mockTenantDb); const service = await TenantAdminService.getTenantById(tenantId); @@ -219,12 +278,16 @@ describe("TenantAdminService", () => { describe("configuration", () => { it("should provide access to tenant configuration", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const mockConfig = { setConfig: vi.fn(), getConfig: vi.fn().mockReturnValue("test-value"), }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); const service = await TenantAdminService.getTenantById(tenantId); const config = service.configuration; @@ -236,6 +299,7 @@ describe("TenantAdminService", () => { describe("updateTenantData", () => { it("should update tenant data successfully", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const updateData = { longName: "Updated Clinic Name", description: ["Updated description"], @@ -250,6 +314,7 @@ describe("TenantAdminService", () => { }; const mockConfig = { setConfig: vi.fn() }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), @@ -257,6 +322,7 @@ describe("TenantAdminService", () => { }; mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); mockCentralDb.update.mockReturnValue(mockUpdateBuilder); const service = await TenantAdminService.getTenantById(tenantId); @@ -273,9 +339,11 @@ describe("TenantAdminService", () => { it("should throw NotFoundError when tenant not found", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const updateData = { longName: "Updated Name" }; const mockConfig = { setConfig: vi.fn() }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), @@ -283,6 +351,7 @@ describe("TenantAdminService", () => { }; mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); mockCentralDb.update.mockReturnValue(mockUpdateBuilder); const service = await TenantAdminService.getTenantById(tenantId); @@ -296,6 +365,7 @@ describe("TenantAdminService", () => { describe("updateTenantConfig", () => { it("should update tenant configuration successfully", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const configUpdates = { brandColor: "#FF0000", maxChannels: 10, @@ -306,7 +376,10 @@ describe("TenantAdminService", () => { setConfig: vi.fn().mockResolvedValue(undefined), }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); const service = await TenantAdminService.getTenantById(tenantId); const result = await service.updateTenantConfig(configUpdates); @@ -325,13 +398,17 @@ describe("TenantAdminService", () => { it("should handle empty config updates", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const configUpdates = {}; const mockConfig = { setConfig: vi.fn().mockResolvedValue(undefined), }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); const service = await TenantAdminService.getTenantById(tenantId); const result = await service.updateTenantConfig(configUpdates); @@ -342,13 +419,17 @@ describe("TenantAdminService", () => { it("should propagate config errors", async () => { const tenantId = "tenant-123"; + const mockTenant = createMockTenant(tenantId); const configUpdates = { brandColor: "#FF0000" }; const mockConfig = { setConfig: vi.fn().mockRejectedValue(new Error("Config error")), }; + const mockSelectBuilder = createMockSelectBuilder([mockTenant]); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); const service = await TenantAdminService.getTenantById(tenantId); @@ -487,22 +568,22 @@ describe("TenantAdminService", () => { }); }); - it("should throw NotFoundError when tenant does not exist", async () => { + it("should throw NotFoundError when tenant does not exist during deletion", async () => { const tenantId = "non-existent"; - const mockSelectBuilder = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]), // Empty result - }; + // For deleteTenant: create a service instance with manually set tenant ID + const service = new (TenantAdminService as any)(tenantId); - const mockConfig = { setConfig: vi.fn() }; - mockTenantConfig.create.mockResolvedValue(mockConfig); + // Set up mock for the internal select query in deleteTenant (when #tenant is null) + const mockSelectBuilder = createMockSelectBuilder([]); // Empty result = tenant not found mockCentralDb.select.mockReturnValue(mockSelectBuilder); - const service = await TenantAdminService.getTenantById(tenantId); - await expect(service.deleteTenant()).rejects.toThrow("Tenant with ID non-existent not found"); + + expect(mockCentralDb.select).toHaveBeenCalled(); + expect(mockSelectBuilder.from).toHaveBeenCalled(); + expect(mockSelectBuilder.where).toHaveBeenCalled(); + expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1); }); it("should continue with deletion even if database drop fails", async () => { diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 4f215b0..f6f2ef1 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -208,16 +208,18 @@ export class TenantAdminService { try { const tenant = new TenantAdminService(id); tenant.#config = await TenantConfig.create(id); - const data = await tenant.#db - ?.select() + const data = await centralDb + .select() .from(centralSchema.tenant) .where(eq(centralSchema.tenant.id, id)) .limit(1); - if (data) { + if (data.length > 0) { tenant.#tenant = data[0]; + } else { + throw new NotFoundError(`Tenant with ID ${id} not found`); } - log.debug("Tenant service loaded successfully", { tenantId: id }); + log.debug("Tenant service loaded successfully", { tenantId: id, data: tenant.#tenant }); return tenant; } catch (error) { log.error("Failed to get tenant by ID", { tenantId: id, error: String(error) }); diff --git a/src/routes/api/tenants/[id]/+server.ts b/src/routes/api/tenants/[id]/+server.ts index 1d0d2b6..29c3dc8 100644 --- a/src/routes/api/tenants/[id]/+server.ts +++ b/src/routes/api/tenants/[id]/+server.ts @@ -48,10 +48,15 @@ registerOpenAPIRoute("/tenants/{id}", "PUT", { description: "Short name for the tenant (4-15 characters)", example: "acme-corp", }, - description: { - type: "string", + descriptions: { + type: "array", description: "Description of the tenant organization", - example: "Leading provider of innovative solutions", + example: `["Leading provider of innovative solutions"]`, + }, + languages: { + type: "array", + description: "Active languages of the tenant organization", + example: `["en", "de"]`, }, logo: { type: "string", @@ -454,6 +459,8 @@ export const GET: RequestHandler = async ({ params, locals }) => { const tenantService = await TenantAdminService.getTenantById(tenantId); const tenantData = tenantService.tenantData; + log.debug("Retrieved tenant data", { tenantService }); + if (!tenantData) { throw new NotFoundError(ERRORS.TENANTS.NOT_FOUND); } diff --git a/tenant-migrations/meta/0003_snapshot.json b/tenant-migrations/meta/0003_snapshot.json index 6e28c45..83b6cc0 100644 --- a/tenant-migrations/meta/0003_snapshot.json +++ b/tenant-migrations/meta/0003_snapshot.json @@ -91,12 +91,8 @@ "name": "agent_absence_agent_id_agent_id_fk", "tableFrom": "agent_absence", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -200,12 +196,8 @@ "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", "tableFrom": "appointment", "tableTo": "client_appointment_tunnel", - "columnsFrom": [ - "tunnel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -213,12 +205,8 @@ "name": "appointment_channel_id_channel_id_fk", "tableFrom": "appointment", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -265,12 +253,8 @@ "name": "appointment_key_share_appointment_id_appointment_id_fk", "tableFrom": "appointment_key_share", "tableTo": "appointment", - "columnsFrom": [ - "appointment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -412,12 +396,8 @@ "name": "channel_agent_channel_id_channel_id_fk", "tableFrom": "channel_agent", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -425,12 +405,8 @@ "name": "channel_agent_agent_id_agent_id_fk", "tableFrom": "channel_agent", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -464,12 +440,8 @@ "name": "channel_slot_template_channel_id_channel_id_fk", "tableFrom": "channel_slot_template", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -477,12 +449,8 @@ "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", "tableFrom": "channel_slot_template", "tableTo": "slotTemplate", - "columnsFrom": [ - "slot_template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -542,9 +510,7 @@ "client_hash_key_unique": { "name": "client_hash_key_unique", "nullsNotDistinct": false, - "columns": [ - "hash_key" - ] + "columns": ["hash_key"] } }, "policies": {}, @@ -608,9 +574,7 @@ "client_appointment_tunnel_email_hash_unique": { "name": "client_appointment_tunnel_email_hash_unique", "nullsNotDistinct": false, - "columns": [ - "email_hash" - ] + "columns": ["email_hash"] } }, "policies": {}, @@ -660,12 +624,8 @@ "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", - "columnsFrom": [ - "tunnel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -790,13 +750,7 @@ "public.appointment_status": { "name": "appointment_status", "schema": "public", - "values": [ - "NEW", - "CONFIRMED", - "HELD", - "REJECTED", - "NO_SHOW" - ] + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] } }, "schemas": {}, @@ -809,4 +763,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 1a808af..5a32929 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -31,4 +31,4 @@ "breakpoints": true } ] -} \ No newline at end of file +}