feat: implement tenant service with bootstrap functionality and error handling

This commit is contained in:
2026-06-26 13:43:39 -03:00
parent 23e8da345a
commit ac9ba12045
7 changed files with 243 additions and 4 deletions

View File

@@ -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)
]
};

View File

@@ -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);
});
});

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { TenantService } from './tenant.service';
export function tenantBootstrap(): Promise<void> {
const tenantService = inject(TenantService);
return tenantService.bootstrap().then(() => undefined);
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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<Tenant | null>(null);
readonly tenant = this.tenantState.asReadonly();
async bootstrap(): Promise<Tenant | null> {
if (!isPlatformBrowser(this.platformId)) {
return null;
}
const domain = this.resolveDomain();
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${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;
}
}

View File

@@ -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 = `
<section style="font-family: Arial, sans-serif; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #f5f5f5; color: #1f1f1f;">
<div style="max-width: 480px; width: 100%; background: #ffffff; border: 1px solid #d9d9d9; border-radius: 12px; padding: 24px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);">
<h1 style="margin: 0 0 12px; font-size: 24px;">Error al cargar el tenant</h1>
<p style="margin: 0; line-height: 1.5;">${message}</p>
</div>
</section>
`;
}
}
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
.catch((err) => {
console.error(err);
renderBootstrapError(err);
});