refactor: migrate tenant bootstrap to use SSR state caching and update button components to use CSS tenant variables
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
23
src/app/core/services/tenant-ssr-cache.store.ts
Normal file
23
src/app/core/services/tenant-ssr-cache.store.ts
Normal 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');
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user