refactor: migrate tenant bootstrap to use SSR state caching and update button components to use CSS tenant variables

This commit is contained in:
2026-06-28 23:09:26 +00:00
parent 7ee103b77b
commit 2cbe681995
27 changed files with 1042 additions and 189 deletions

View File

@@ -5,18 +5,7 @@ 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'
});
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [

View File

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

View File

@@ -0,0 +1,23 @@
import { makeStateKey } from '@angular/core';
import { Tenant } from './tenant.interface';
export type TenantBootstrapStatus = 'idle' | 'ready' | 'not-found';
export type TenantSsrState =
| {
status: 'ready';
tenant: Tenant;
}
| {
status: 'not-found';
};
export const DEFAULT_TENANT_BRANDING = {
primaryColor: '#102bda',
secondaryColor: '#9e9e9e',
dangerColor: '#fd4c4c',
headerFooterBgColor: '#313131'
} as const;
export const TENANT_SSR_STATE_KEY = makeStateKey<TenantSsrState | null>('tenant-ssr-state');

View File

@@ -1,15 +1,37 @@
import { PLATFORM_ID } from '@angular/core';
import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } 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 { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
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'
};
const tenantResponse: TenantBootstrapResponse = {
data: tenant
};
describe('TenantService', () => {
beforeEach(() => {
window.history.replaceState({}, '', 'http://localhost:4200/store');
try {
window.history.replaceState({}, '', 'http://localhost:4200/');
} catch (e) {
// Ignore SecurityError under test environments
}
});
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
@@ -21,30 +43,124 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
const request = httpController.expectOne(`${environment.url}tenants/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(tenantResponse);
request.flush(response);
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant);
await expect(bootstrapPromise).resolves.toEqual(response.data);
expect(service.tenant()).toEqual(response.data);
expect(service.getTenant()).toEqual(response.data);
httpController.verify();
});
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
const transferState = TestBed.inject(TransferState);
const httpController = TestBed.inject(HttpTestingController);
transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready',
tenant
});
await expect(service.bootstrap()).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toBeNull();
httpController.expectNone(() => true);
httpController.verify();
});
it('uses the forwarded host during SSR and stores the resolved tenant in TransferState', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: PLATFORM_ID, useValue: 'server' },
{
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
host: 'ignored.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const transferState = TestBed.inject(TransferState);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
request.flush(tenantResponse);
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready',
tenant
});
expect(responseInit.status).toBeUndefined();
httpController.verify();
});
it('stores a not-found state and sets the SSR response status to 404 when the tenant does not exist', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: PLATFORM_ID, useValue: 'server' },
{
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'missing.example.com:8443'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const transferState = TestBed.inject(TransferState);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.status()).toBe('not-found');
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found'
});
expect(responseInit.status).toBe(404);
httpController.verify();
});
@@ -58,33 +174,50 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
request.flush({});
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
expect(service.status()).toBe('idle');
expect(service.tenant()).toBeNull();
httpController.verify();
});
it('skips the HTTP bootstrap on the server platform', async () => {
it('propagates non-404 backend failures', async () => {
const responseInit: ResponseInit = {};
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: PLATFORM_ID, useValue: 'server' }
{ provide: PLATFORM_ID, useValue: 'server' },
{
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'broken.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
await expect(service.bootstrap()).resolves.toBeNull();
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
await expect(bootstrapPromise).rejects.toThrow('Http failure response');
expect(service.status()).toBe('idle');
expect(responseInit.status).toBeUndefined();
httpController.expectNone(() => true);
httpController.verify();
});
});

View File

@@ -1,10 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {
inject,
Injectable,
PLATFORM_ID,
REQUEST,
RESPONSE_INIT,
signal,
TransferState
} from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
TenantBootstrapStatus,
TenantSsrState
} from './tenant-ssr-cache.store';
@Injectable({
providedIn: 'root'
@@ -12,40 +26,141 @@ import { Tenant, TenantBootstrapResponse } from './tenant.interface';
export class TenantService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
private readonly transferState = inject(TransferState);
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
private readonly tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle');
readonly tenant = this.tenantState.asReadonly();
readonly status = this.statusState.asReadonly();
getTenant(): Tenant | null {
return this.tenantState();
}
async bootstrap(): Promise<Tenant | null> {
if (!isPlatformBrowser(this.platformId)) {
return null;
async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') {
return;
}
if (this.isDiscoveringRoutes) {
this.statusState.set('ready');
return;
}
const transferredState = this.transferState.get(TENANT_SSR_STATE_KEY, null);
if (transferredState) {
this.transferState.remove(TENANT_SSR_STATE_KEY);
this.applyState(transferredState);
return;
}
const domain = this.resolveDomain();
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
);
if (!response?.data) {
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
try {
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
);
if (!response?.data) {
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
}
this.setReady(response.data);
this.persistSsrState({
status: 'ready',
tenant: response.data
});
} catch (error) {
if (this.isNotFoundError(error)) {
this.setNotFound();
this.persistSsrState({ status: 'not-found' });
if (this.responseInit) {
this.responseInit.status = 404;
}
return;
}
throw error;
}
this.tenantState.set(response.data);
return response.data;
}
private resolveDomain(): string {
const domain = window.location.hostname;
if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname;
if (!domain) {
if (!domain) {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return this.normalizeHost(domain);
}
const requestHost =
this.request?.headers.get('x-forwarded-host') ??
this.request?.headers.get('host') ??
(this.request ? new URL(this.request.url).host : null);
if (!requestHost) {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return domain;
return this.normalizeHost(requestHost);
}
private normalizeHost(host: string): string {
const [firstHost = ''] = host.split(',');
const normalizedHost = firstHost.trim().toLowerCase();
if (!normalizedHost) {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
if (normalizedHost.startsWith('[')) {
const closingBracketIndex = normalizedHost.indexOf(']');
if (closingBracketIndex >= 0) {
return normalizedHost.slice(1, closingBracketIndex);
}
}
return normalizedHost.replace(/:\d+$/, '');
}
private applyState(state: TenantSsrState): void {
if (state.status === 'ready') {
this.setReady(state.tenant);
return;
}
this.setNotFound();
}
private setReady(tenant: Tenant): void {
this.tenantState.set(tenant);
this.statusState.set('ready');
}
private setNotFound(): void {
this.tenantState.set(null);
this.statusState.set('not-found');
}
private persistSsrState(state: TenantSsrState): void {
if (isPlatformServer(this.platformId)) {
this.transferState.set(TENANT_SSR_STATE_KEY, state);
}
}
private isNotFoundError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 404;
}
}