From ac9ba120455edbab71f91a6c6b3794463b346ebe Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 26 Jun 2026 13:43:39 -0300 Subject: [PATCH] feat: implement tenant service with bootstrap functionality and error handling --- src/app/app.config.ts | 11 ++- .../core/services/tenant-bootstrap.spec.ts | 55 ++++++++++++ src/app/core/services/tenant-bootstrap.ts | 9 ++ src/app/core/services/tenant.interface.ts | 16 ++++ src/app/core/services/tenant.service.spec.ts | 88 +++++++++++++++++++ src/app/core/services/tenant.service.ts | 47 ++++++++++ src/main.ts | 21 ++++- 7 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 src/app/core/services/tenant-bootstrap.spec.ts create mode 100644 src/app/core/services/tenant-bootstrap.ts create mode 100644 src/app/core/services/tenant.interface.ts create mode 100644 src/app/core/services/tenant.service.spec.ts create mode 100644 src/app/core/services/tenant.service.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 0f4fd64..e0d9b22 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,12 +1,17 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideClientHydration } from '@angular/platform-browser'; import { routes } from './app.routes'; -import { provideClientHydration } from '@angular/platform-browser'; +import { tenantBootstrap } from './core/services/tenant-bootstrap'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), provideClientHydration() + provideRouter(routes), + provideClientHydration(), + provideHttpClient(), + provideAppInitializer(tenantBootstrap) ] }; diff --git a/src/app/core/services/tenant-bootstrap.spec.ts b/src/app/core/services/tenant-bootstrap.spec.ts new file mode 100644 index 0000000..d5a056a --- /dev/null +++ b/src/app/core/services/tenant-bootstrap.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; + +import { tenantBootstrap } from './tenant-bootstrap'; +import { TenantService } from './tenant.service'; + +describe('tenantBootstrap', () => { + it('waits for TenantService.bootstrap to resolve', async () => { + const bootstrapSpy = vi.fn().mockResolvedValue({ + id: 1, + codigo: 'test', + nombre: 'Test Tennant', + dominio: 'localhost', + primary_color: '#6376F3', + secondary_color: '#A0A0A0', + danger_color: '#FF8888', + header_footer_bg_color: '#313131', + header_logo: 'https://example.com/header.png', + footer_logo: 'https://example.com/footer.png' + }); + + TestBed.configureTestingModule({ + providers: [ + { + provide: TenantService, + useValue: { + bootstrap: bootstrapSpy + } + } + ] + }); + + await expect(TestBed.runInInjectionContext(() => tenantBootstrap())).resolves.toBeUndefined(); + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + }); + + it('propagates tenant bootstrap failures', async () => { + const bootstrapSpy = vi.fn().mockRejectedValue(new Error('bootstrap failed')); + + TestBed.configureTestingModule({ + providers: [ + { + provide: TenantService, + useValue: { + bootstrap: bootstrapSpy + } + } + ] + }); + + await expect(TestBed.runInInjectionContext(() => tenantBootstrap())).rejects.toThrow( + 'bootstrap failed' + ); + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/core/services/tenant-bootstrap.ts b/src/app/core/services/tenant-bootstrap.ts new file mode 100644 index 0000000..fa990fb --- /dev/null +++ b/src/app/core/services/tenant-bootstrap.ts @@ -0,0 +1,9 @@ +import { inject } from '@angular/core'; + +import { TenantService } from './tenant.service'; + +export function tenantBootstrap(): Promise { + const tenantService = inject(TenantService); + + return tenantService.bootstrap().then(() => undefined); +} diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts new file mode 100644 index 0000000..81babb3 --- /dev/null +++ b/src/app/core/services/tenant.interface.ts @@ -0,0 +1,16 @@ +export interface Tenant { + id: number; + codigo: string; + nombre: string; + dominio: string; + primary_color: string; + secondary_color: string; + danger_color: string; + header_footer_bg_color: string; + header_logo: string; + footer_logo: string; +} + +export interface TenantBootstrapResponse { + data: Tenant; +} diff --git a/src/app/core/services/tenant.service.spec.ts b/src/app/core/services/tenant.service.spec.ts new file mode 100644 index 0000000..5585a09 --- /dev/null +++ b/src/app/core/services/tenant.service.spec.ts @@ -0,0 +1,88 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; + +import { environment } from '../../../environments/environment'; +import { TenantBootstrapResponse } from './tenant.interface'; +import { TenantService } from './tenant.service'; + +describe('TenantService', () => { + beforeEach(() => { + window.history.replaceState({}, '', 'http://localhost:4200/store'); + }); + + it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), TenantService] + }); + + const service = TestBed.inject(TenantService); + const httpController = TestBed.inject(HttpTestingController); + + const bootstrapPromise = service.bootstrap(); + const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`); + + expect(request.request.method).toBe('GET'); + + const response: TenantBootstrapResponse = { + data: { + id: 1, + codigo: 'test', + nombre: 'Test Tennant', + dominio: 'localhost', + primary_color: '#6376F3', + secondary_color: '#A0A0A0', + danger_color: '#FF8888', + header_footer_bg_color: '#313131', + header_logo: 'https://example.com/header.png', + footer_logo: 'https://example.com/footer.png' + } + }; + + request.flush(response); + + await expect(bootstrapPromise).resolves.toEqual(response.data); + expect(service.tenant()).toEqual(response.data); + + httpController.verify(); + }); + + it('rejects when the bootstrap response does not contain tenant data', async () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), TenantService] + }); + + const service = TestBed.inject(TenantService); + const httpController = TestBed.inject(HttpTestingController); + + const bootstrapPromise = service.bootstrap(); + const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`); + + request.flush({}); + + await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data'); + + httpController.verify(); + }); + + it('skips the HTTP bootstrap on the server platform', async () => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + TenantService, + { provide: PLATFORM_ID, useValue: 'server' } + ] + }); + + const service = TestBed.inject(TenantService); + const httpController = TestBed.inject(HttpTestingController); + + await expect(service.bootstrap()).resolves.toBeNull(); + expect(service.tenant()).toBeNull(); + + httpController.expectNone(() => true); + httpController.verify(); + }); +}); diff --git a/src/app/core/services/tenant.service.ts b/src/app/core/services/tenant.service.ts new file mode 100644 index 0000000..9c2ff3c --- /dev/null +++ b/src/app/core/services/tenant.service.ts @@ -0,0 +1,47 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { Tenant, TenantBootstrapResponse } from './tenant.interface'; + +@Injectable({ + providedIn: 'root' +}) +export class TenantService { + private readonly http = inject(HttpClient); + private readonly platformId = inject(PLATFORM_ID); + private readonly tenantState = signal(null); + + readonly tenant = this.tenantState.asReadonly(); + + async bootstrap(): Promise { + if (!isPlatformBrowser(this.platformId)) { + return null; + } + + const domain = this.resolveDomain(); + const response = await firstValueFrom( + this.http.get(`${environment.url}tennants/bootstrap/${domain}`) + ); + + if (!response?.data) { + throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`); + } + + this.tenantState.set(response.data); + + return response.data; + } + + private resolveDomain(): string { + const domain = window.location.hostname; + + if (!domain) { + throw new Error('Tenant bootstrap could not resolve the current domain.'); + } + + return domain; + } +} diff --git a/src/main.ts b/src/main.ts index 5df75f9..4f5551c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,5 +2,24 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; +function renderBootstrapError(error: unknown): void { + const message = error instanceof Error ? error.message : 'No se pudo cargar la configuracion del tenant.'; + const root = document.querySelector('app-root'); + + if (root) { + root.innerHTML = ` +
+
+

Error al cargar el tenant

+

${message}

+
+
+ `; + } +} + bootstrapApplication(App, appConfig) - .catch((err) => console.error(err)); + .catch((err) => { + console.error(err); + renderBootstrapError(err); + });