feat(tenant-service): implement bootstrap request timeout and enhance request context resolution

This commit is contained in:
2026-08-14 15:17:22 -03:00
parent e4670491c7
commit d92b5a7623
11 changed files with 297 additions and 53 deletions

View File

@@ -2,8 +2,10 @@ import { PLATFORM_ID, 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 { TimeoutError } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
@@ -181,6 +183,43 @@ describe('AuthService', () => {
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', () => {
TestBed.configureTestingModule({
providers: [

View File

@@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
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 { BaseApiService } from '../base-api.service';
@@ -20,6 +20,7 @@ import {
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.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_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 transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@@ -175,7 +177,9 @@ export class AuthService extends BaseApiService {
}
try {
const user = await firstValueFrom(this.loadCurrentUser());
const user = await firstValueFrom(
this.loadCurrentUser().pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);

View 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,
},
);

View File

@@ -2,8 +2,10 @@ import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/cor
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service';
@@ -29,15 +31,15 @@ const tenant: Tenant = {
{
id: 2,
nombre: 'Manga corta',
subcategories: []
}
]
}
]
subcategories: [],
},
],
},
],
};
const tenantResponse: TenantBootstrapResponse = {
data: tenant
data: tenant,
};
describe('TenantService', () => {
@@ -51,14 +53,16 @@ describe('TenantService', () => {
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
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');
@@ -74,7 +78,7 @@ describe('TenantService', () => {
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
});
const service = TestBed.inject(TenantService);
@@ -83,7 +87,7 @@ describe('TenantService', () => {
transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready',
tenant
tenant,
});
await expect(service.bootstrap()).resolves.toBeUndefined();
@@ -109,12 +113,12 @@ describe('TenantService', () => {
useValue: new Request('https://internal.example/render', {
headers: {
'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);
@@ -122,7 +126,9 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
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);
@@ -131,7 +137,7 @@ describe('TenantService', () => {
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready',
tenant
tenant,
});
expect(responseInit.status).toBeUndefined();
@@ -151,12 +157,12 @@ describe('TenantService', () => {
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
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);
@@ -164,7 +170,9 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
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' });
@@ -173,7 +181,7 @@ describe('TenantService', () => {
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found'
status: 'not-found',
});
expect(responseInit.status).toBe(404);
@@ -182,14 +190,16 @@ describe('TenantService', () => {
it('rejects when the bootstrap response does not contain tenant data', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
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({});
@@ -213,19 +223,21 @@ describe('TenantService', () => {
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
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 httpController = TestBed.inject(HttpTestingController);
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' });
@@ -235,4 +247,39 @@ describe('TenantService', () => {
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();
}
});
});

View File

@@ -7,22 +7,23 @@ import {
REQUEST,
RESPONSE_INIT,
signal,
TransferState
TransferState,
} from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, timeout } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
TenantBootstrapStatus,
TenantSsrState
TenantSsrState,
} from './tenant-ssr-cache.store';
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class TenantService extends BaseApiService {
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 transferState = inject(TransferState);
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 statusState = signal<TenantBootstrapStatus>('idle');
@@ -48,7 +50,6 @@ export class TenantService extends BaseApiService {
return `${environment.url}tenants/${tenant.codigo}`;
}
async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') {
return;
@@ -69,13 +70,18 @@ export class TenantService extends BaseApiService {
return;
}
const domain = this.resolveDomain();
const { domain, path } = this.resolveRequestContext();
try {
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(
`${environment.url}tenants/bootstrap/${domain}`
)
this.http
.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
params: {
dominio: domain,
path,
},
})
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
if (!response?.data) {
@@ -85,7 +91,7 @@ export class TenantService extends BaseApiService {
this.setReady(response.data);
this.persistSsrState({
status: 'ready',
tenant: response.data
tenant: response.data,
});
} catch (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)) {
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.');
}
return this.normalizeHost(domain);
return {
domain: this.normalizeHost(domain),
path: this.normalizePath(window.location.pathname),
};
}
const requestHost =
@@ -123,7 +132,14 @@ export class TenantService extends BaseApiService {
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 {
@@ -145,6 +161,16 @@ export class TenantService extends BaseApiService {
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 {
if (state.status === 'ready') {
this.setReady(state.tenant);