feat(tenant-service): implement bootstrap request timeout and enhance request context resolution
This commit is contained in:
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -7,7 +7,7 @@
|
|||||||
"type": "chrome",
|
"type": "chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "npm: start",
|
"preLaunchTask": "npm: start",
|
||||||
"url": "http://localhost:4200/"
|
"url": "http://localhost:4300/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ng test",
|
"name": "ng test",
|
||||||
|
|||||||
28
src/app/app.config.spec.ts
Normal file
28
src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { HttpRequest } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { isStoreCatalogRequest } from './app.config';
|
||||||
|
|
||||||
|
describe('isStoreCatalogRequest', () => {
|
||||||
|
it.each([
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog?currency=ARS',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog/featured-groups/7/items?page=2',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog-items?q=remera&page=1',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog-items/42?variant_id=3',
|
||||||
|
])('includes GET %s in the hydration transfer cache', (url) => {
|
||||||
|
expect(isStoreCatalogRequest(new HttpRequest('GET', url))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/productos'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/catalogue'],
|
||||||
|
])('excludes %s %s from the hydration transfer cache', (method, url) => {
|
||||||
|
const request =
|
||||||
|
method === 'GET' ? new HttpRequest('GET', url) : new HttpRequest('POST', url, null);
|
||||||
|
|
||||||
|
expect(isStoreCatalogRequest(request)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,10 +13,12 @@ import { authInterceptor } from './core/services/auth/auth.interceptor';
|
|||||||
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
|
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
|
||||||
import { tenantBootstrap } from './core/services/tenant-bootstrap';
|
import { tenantBootstrap } from './core/services/tenant-bootstrap';
|
||||||
|
|
||||||
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
|
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
|
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
|
||||||
|
request.urlWithParams,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,19 @@ describe('app routes', () => {
|
|||||||
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
|
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the tenant not found screen at / without redirecting to itself', async () => {
|
||||||
|
const { fixture, router } = await renderAppAt(
|
||||||
|
'/',
|
||||||
|
createTenantServiceStub('not-found', null),
|
||||||
|
createAuthServiceStub()
|
||||||
|
);
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(router.url).toBe('/');
|
||||||
|
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
|
||||||
|
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
|
||||||
|
});
|
||||||
|
|
||||||
it('redirects unauthenticated users from /checkout to /login', async () => {
|
it('redirects unauthenticated users from /checkout to /login', async () => {
|
||||||
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
|
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
|
||||||
|
|
||||||
|
|||||||
76
src/app/core/guards/menu.guard.spec.ts
Normal file
76
src/app/core/guards/menu.guard.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter, Router, UrlTree } from '@angular/router';
|
||||||
|
|
||||||
|
import { Tenant } from '../services/tenant.interface';
|
||||||
|
import { TenantService } from '../services/tenant.service';
|
||||||
|
import { hasMenuGuard } from './menu.guard';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [],
|
||||||
|
menues: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'index',
|
||||||
|
label: 'Inicio',
|
||||||
|
parent_menu_code: null,
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { tenant: () => currentTenant },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return TestBed.runInInjectionContext(() =>
|
||||||
|
hasMenuGuard(menuCode)(null as never, { url } as never),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hasMenuGuard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a route exposed by the tenant menu', () => {
|
||||||
|
expect(runGuard('index', '/', tenant)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels navigation when the tenant is missing', () => {
|
||||||
|
expect(runGuard('index', '/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects a missing menu route to the store root', () => {
|
||||||
|
const result = runGuard('checkout', '/checkout', tenant);
|
||||||
|
|
||||||
|
expect(result instanceof UrlTree).toBe(true);
|
||||||
|
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the store root when its menu is missing to avoid a self-redirect', () => {
|
||||||
|
const tenantWithoutMenus = { ...tenant, menues: [] };
|
||||||
|
|
||||||
|
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,19 +4,19 @@ import { findMenu } from '../services/menu.utils';
|
|||||||
import { TenantService } from '../services/tenant.service';
|
import { TenantService } from '../services/tenant.service';
|
||||||
|
|
||||||
export const hasMenuGuard = (menuCode: string): CanActivateFn => {
|
export const hasMenuGuard = (menuCode: string): CanActivateFn => {
|
||||||
return () => {
|
return (_route, state) => {
|
||||||
const tenantService = inject(TenantService);
|
const tenantService = inject(TenantService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
const tenant = tenantService.tenant();
|
const tenant = tenantService.tenant();
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
return router.createUrlTree(['/']);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
|
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
|
||||||
|
|
||||||
if (hasMenu) {
|
if (hasMenu || state.url === '/') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { PLATFORM_ID, TransferState } from '@angular/core';
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TimeoutError } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CookieService } from '../cookie/cookie.service';
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
import { TenantService } from '../tenant.service';
|
import { TenantService } from '../tenant.service';
|
||||||
@@ -181,6 +183,43 @@ describe('AuthService', () => {
|
|||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('propagates a /me timeout without clearing the local session', async () => {
|
||||||
|
cookieStore['shopit.front.auth.token'] = 'valid-token';
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
|
||||||
|
TransferState,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
|
||||||
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
await rejection;
|
||||||
|
|
||||||
|
expect(request.cancelled).toBe(true);
|
||||||
|
expect(service.user()).toBeNull();
|
||||||
|
expect(service.token()).toBe('valid-token');
|
||||||
|
expect(cookieStore['shopit.front.auth.token']).toBe('valid-token');
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('registers without creating a session', () => {
|
it('registers without creating a session', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common';
|
|||||||
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||||
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
||||||
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
import { firstValueFrom, map, Observable, of, tap, timeout } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BaseApiService } from '../base-api.service';
|
import { BaseApiService } from '../base-api.service';
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './auth.interfaces';
|
} from './auth.interfaces';
|
||||||
import { CookieService } from '../cookie/cookie.service';
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
import { TenantService } from '../tenant.service';
|
import { TenantService } from '../tenant.service';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
|
||||||
|
|
||||||
const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token';
|
const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token';
|
||||||
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
||||||
@@ -33,6 +34,7 @@ export class AuthService extends BaseApiService {
|
|||||||
private readonly cookieService = inject(CookieService);
|
private readonly cookieService = inject(CookieService);
|
||||||
private readonly transferState = inject(TransferState);
|
private readonly transferState = inject(TransferState);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
private readonly userState = signal<AuthUser | null>(null);
|
private readonly userState = signal<AuthUser | null>(null);
|
||||||
private readonly tokenState = signal<string | null>(null);
|
private readonly tokenState = signal<string | null>(null);
|
||||||
@@ -175,7 +177,9 @@ export class AuthService extends BaseApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await firstValueFrom(this.loadCurrentUser());
|
const user = await firstValueFrom(
|
||||||
|
this.loadCurrentUser().pipe(timeout(this.bootstrapRequestTimeoutMs)),
|
||||||
|
);
|
||||||
|
|
||||||
if (isPlatformServer(this.platformId)) {
|
if (isPlatformServer(this.platformId)) {
|
||||||
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
||||||
|
|||||||
9
src/app/core/services/bootstrap-request-timeout.ts
Normal file
9
src/app/core/services/bootstrap-request-timeout.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
|
export const BOOTSTRAP_REQUEST_TIMEOUT_MS = new InjectionToken<number>(
|
||||||
|
'BOOTSTRAP_REQUEST_TIMEOUT_MS',
|
||||||
|
{
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => 10_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -2,8 +2,10 @@ import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/cor
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TimeoutError } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
|
||||||
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||||
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
|
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
|
||||||
import { TenantService } from './tenant.service';
|
import { TenantService } from './tenant.service';
|
||||||
@@ -29,15 +31,15 @@ const tenant: Tenant = {
|
|||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
nombre: 'Manga corta',
|
nombre: 'Manga corta',
|
||||||
subcategories: []
|
subcategories: [],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const tenantResponse: TenantBootstrapResponse = {
|
const tenantResponse: TenantBootstrapResponse = {
|
||||||
data: tenant
|
data: tenant,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('TenantService', () => {
|
describe('TenantService', () => {
|
||||||
@@ -51,14 +53,16 @@ describe('TenantService', () => {
|
|||||||
|
|
||||||
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
|
||||||
|
);
|
||||||
|
|
||||||
expect(request.request.method).toBe('GET');
|
expect(request.request.method).toBe('GET');
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ describe('TenantService', () => {
|
|||||||
|
|
||||||
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
|
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
@@ -83,7 +87,7 @@ describe('TenantService', () => {
|
|||||||
|
|
||||||
transferState.set(TENANT_SSR_STATE_KEY, {
|
transferState.set(TENANT_SSR_STATE_KEY, {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
tenant
|
tenant,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.bootstrap()).resolves.toBeUndefined();
|
await expect(service.bootstrap()).resolves.toBeUndefined();
|
||||||
@@ -109,12 +113,12 @@ describe('TenantService', () => {
|
|||||||
useValue: new Request('https://internal.example/render', {
|
useValue: new Request('https://internal.example/render', {
|
||||||
headers: {
|
headers: {
|
||||||
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
|
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
|
||||||
host: 'ignored.example.com'
|
host: 'ignored.example.com',
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
},
|
},
|
||||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
@@ -122,7 +126,9 @@ describe('TenantService', () => {
|
|||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=store.example.com&path=/render`,
|
||||||
|
);
|
||||||
|
|
||||||
request.flush(tenantResponse);
|
request.flush(tenantResponse);
|
||||||
|
|
||||||
@@ -131,7 +137,7 @@ describe('TenantService', () => {
|
|||||||
expect(service.tenant()).toEqual(tenant);
|
expect(service.tenant()).toEqual(tenant);
|
||||||
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
tenant
|
tenant,
|
||||||
});
|
});
|
||||||
expect(responseInit.status).toBeUndefined();
|
expect(responseInit.status).toBeUndefined();
|
||||||
|
|
||||||
@@ -151,12 +157,12 @@ describe('TenantService', () => {
|
|||||||
provide: REQUEST,
|
provide: REQUEST,
|
||||||
useValue: new Request('https://internal.example/render', {
|
useValue: new Request('https://internal.example/render', {
|
||||||
headers: {
|
headers: {
|
||||||
host: 'missing.example.com:8443'
|
host: 'missing.example.com:8443',
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
},
|
},
|
||||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
@@ -164,7 +170,9 @@ describe('TenantService', () => {
|
|||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=missing.example.com&path=/render`,
|
||||||
|
);
|
||||||
|
|
||||||
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
|
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
|
||||||
|
|
||||||
@@ -173,7 +181,7 @@ describe('TenantService', () => {
|
|||||||
expect(service.tenant()).toBeNull();
|
expect(service.tenant()).toBeNull();
|
||||||
expect(service.getTenant()).toBeNull();
|
expect(service.getTenant()).toBeNull();
|
||||||
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||||
status: 'not-found'
|
status: 'not-found',
|
||||||
});
|
});
|
||||||
expect(responseInit.status).toBe(404);
|
expect(responseInit.status).toBe(404);
|
||||||
|
|
||||||
@@ -182,14 +190,16 @@ describe('TenantService', () => {
|
|||||||
|
|
||||||
it('rejects when the bootstrap response does not contain tenant data', async () => {
|
it('rejects when the bootstrap response does not contain tenant data', async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
|
||||||
|
);
|
||||||
|
|
||||||
request.flush({});
|
request.flush({});
|
||||||
|
|
||||||
@@ -213,19 +223,21 @@ describe('TenantService', () => {
|
|||||||
provide: REQUEST,
|
provide: REQUEST,
|
||||||
useValue: new Request('https://internal.example/render', {
|
useValue: new Request('https://internal.example/render', {
|
||||||
headers: {
|
headers: {
|
||||||
host: 'broken.example.com'
|
host: 'broken.example.com',
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
},
|
},
|
||||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=broken.example.com&path=/render`,
|
||||||
|
);
|
||||||
|
|
||||||
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
|
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
|
||||||
|
|
||||||
@@ -235,4 +247,39 @@ describe('TenantService', () => {
|
|||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects and cancels a tenant bootstrap request that exceeds the configured timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
|
||||||
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
await rejection;
|
||||||
|
|
||||||
|
expect(request.cancelled).toBe(true);
|
||||||
|
expect(service.status()).toBe('idle');
|
||||||
|
expect(service.tenant()).toBeNull();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,22 +7,23 @@ import {
|
|||||||
REQUEST,
|
REQUEST,
|
||||||
RESPONSE_INIT,
|
RESPONSE_INIT,
|
||||||
signal,
|
signal,
|
||||||
TransferState
|
TransferState,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
|
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom, timeout } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { BaseApiService } from './base-api.service';
|
import { BaseApiService } from './base-api.service';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
|
||||||
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||||
import {
|
import {
|
||||||
TENANT_SSR_STATE_KEY,
|
TENANT_SSR_STATE_KEY,
|
||||||
TenantBootstrapStatus,
|
TenantBootstrapStatus,
|
||||||
TenantSsrState
|
TenantSsrState,
|
||||||
} from './tenant-ssr-cache.store';
|
} from './tenant-ssr-cache.store';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class TenantService extends BaseApiService {
|
export class TenantService extends BaseApiService {
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
@@ -30,6 +31,7 @@ export class TenantService extends BaseApiService {
|
|||||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
private readonly transferState = inject(TransferState);
|
private readonly transferState = inject(TransferState);
|
||||||
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
|
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
|
||||||
|
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
|
||||||
private readonly tenantState = signal<Tenant | null>(null);
|
private readonly tenantState = signal<Tenant | null>(null);
|
||||||
private readonly statusState = signal<TenantBootstrapStatus>('idle');
|
private readonly statusState = signal<TenantBootstrapStatus>('idle');
|
||||||
|
|
||||||
@@ -48,7 +50,6 @@ export class TenantService extends BaseApiService {
|
|||||||
return `${environment.url}tenants/${tenant.codigo}`;
|
return `${environment.url}tenants/${tenant.codigo}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async bootstrap(): Promise<void> {
|
async bootstrap(): Promise<void> {
|
||||||
if (this.statusState() !== 'idle') {
|
if (this.statusState() !== 'idle') {
|
||||||
return;
|
return;
|
||||||
@@ -69,13 +70,18 @@ export class TenantService extends BaseApiService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const domain = this.resolveDomain();
|
const { domain, path } = this.resolveRequestContext();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.get<TenantBootstrapResponse>(
|
this.http
|
||||||
`${environment.url}tenants/bootstrap/${domain}`
|
.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
|
||||||
)
|
params: {
|
||||||
|
dominio: domain,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response?.data) {
|
if (!response?.data) {
|
||||||
@@ -85,7 +91,7 @@ export class TenantService extends BaseApiService {
|
|||||||
this.setReady(response.data);
|
this.setReady(response.data);
|
||||||
this.persistSsrState({
|
this.persistSsrState({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
tenant: response.data
|
tenant: response.data,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isNotFoundError(error)) {
|
if (this.isNotFoundError(error)) {
|
||||||
@@ -103,7 +109,7 @@ export class TenantService extends BaseApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveDomain(): string {
|
private resolveRequestContext(): { domain: string; path: string } {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
const domain = window.location.hostname;
|
const domain = window.location.hostname;
|
||||||
|
|
||||||
@@ -111,7 +117,10 @@ export class TenantService extends BaseApiService {
|
|||||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.normalizeHost(domain);
|
return {
|
||||||
|
domain: this.normalizeHost(domain),
|
||||||
|
path: this.normalizePath(window.location.pathname),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestHost =
|
const requestHost =
|
||||||
@@ -123,7 +132,14 @@ export class TenantService extends BaseApiService {
|
|||||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.normalizeHost(requestHost);
|
const forwardedPath =
|
||||||
|
this.request?.headers.get('x-forwarded-uri') ?? this.request?.headers.get('x-original-uri');
|
||||||
|
const requestPath = forwardedPath ?? (this.request ? new URL(this.request.url).pathname : '/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: this.normalizeHost(requestHost),
|
||||||
|
path: this.normalizePath(requestPath),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeHost(host: string): string {
|
private normalizeHost(host: string): string {
|
||||||
@@ -145,6 +161,16 @@ export class TenantService extends BaseApiService {
|
|||||||
return normalizedHost.replace(/:\d+$/, '');
|
return normalizedHost.replace(/:\d+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePath(path: string): string {
|
||||||
|
const pathname = path.split(/[?#]/, 1)[0]?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!pathname || pathname === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${pathname.replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
private applyState(state: TenantSsrState): void {
|
private applyState(state: TenantSsrState): void {
|
||||||
if (state.status === 'ready') {
|
if (state.status === 'ready') {
|
||||||
this.setReady(state.tenant);
|
this.setReady(state.tenant);
|
||||||
|
|||||||
Reference in New Issue
Block a user