From d92b5a7623f5cf0d452fd4fe4d2d723914455a6a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 14 Aug 2026 15:17:22 -0300 Subject: [PATCH] feat(tenant-service): implement bootstrap request timeout and enhance request context resolution --- .vscode/launch.json | 2 +- src/app/app.config.spec.ts | 28 +++++ src/app/app.config.ts | 6 +- src/app/app.routes.spec.ts | 13 ++ src/app/core/guards/menu.guard.spec.ts | 76 ++++++++++++ src/app/core/guards/menu.guard.ts | 6 +- .../core/services/auth/auth.service.spec.ts | 39 ++++++ src/app/core/services/auth/auth.service.ts | 8 +- .../services/bootstrap-request-timeout.ts | 9 ++ src/app/core/services/tenant.service.spec.ts | 111 +++++++++++++----- src/app/core/services/tenant.service.ts | 52 ++++++-- 11 files changed, 297 insertions(+), 53 deletions(-) create mode 100644 src/app/app.config.spec.ts create mode 100644 src/app/core/guards/menu.guard.spec.ts create mode 100644 src/app/core/services/bootstrap-request-timeout.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 925af83..f8e2f89 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "type": "chrome", "request": "launch", "preLaunchTask": "npm: start", - "url": "http://localhost:4200/" + "url": "http://localhost:4300/" }, { "name": "ng test", diff --git a/src/app/app.config.spec.ts b/src/app/app.config.spec.ts new file mode 100644 index 0000000..300a5eb --- /dev/null +++ b/src/app/app.config.spec.ts @@ -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); + }); +}); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 0d355f5..1404718 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -13,10 +13,12 @@ import { authInterceptor } from './core/services/auth/auth.interceptor'; import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor'; import { tenantBootstrap } from './core/services/tenant-bootstrap'; -function isStoreCatalogRequest(request: HttpRequest): boolean { +export function isStoreCatalogRequest(request: HttpRequest): boolean { return ( request.method === 'GET' && - /\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams) + /\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test( + request.urlWithParams, + ) ); } diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index d533b12..55f51b7 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -233,6 +233,19 @@ describe('app routes', () => { 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 () => { const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false)); diff --git a/src/app/core/guards/menu.guard.spec.ts b/src/app/core/guards/menu.guard.spec.ts new file mode 100644 index 0000000..e8159e6 --- /dev/null +++ b/src/app/core/guards/menu.guard.spec.ts @@ -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); + }); +}); diff --git a/src/app/core/guards/menu.guard.ts b/src/app/core/guards/menu.guard.ts index 0490df5..5dffedb 100644 --- a/src/app/core/guards/menu.guard.ts +++ b/src/app/core/guards/menu.guard.ts @@ -4,19 +4,19 @@ import { findMenu } from '../services/menu.utils'; import { TenantService } from '../services/tenant.service'; export const hasMenuGuard = (menuCode: string): CanActivateFn => { - return () => { + return (_route, state) => { const tenantService = inject(TenantService); const router = inject(Router); const tenant = tenantService.tenant(); if (!tenant) { - return router.createUrlTree(['/']); + return false; } const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined; - if (hasMenu) { + if (hasMenu || state.url === '/') { return true; } diff --git a/src/app/core/services/auth/auth.service.spec.ts b/src/app/core/services/auth/auth.service.spec.ts index e46ec8f..46f7433 100644 --- a/src/app/core/services/auth/auth.service.spec.ts +++ b/src/app/core/services/auth/auth.service.spec.ts @@ -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: [ diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 98f36b2..73f921f 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -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('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(null); private readonly tokenState = signal(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); diff --git a/src/app/core/services/bootstrap-request-timeout.ts b/src/app/core/services/bootstrap-request-timeout.ts new file mode 100644 index 0000000..6bb2d37 --- /dev/null +++ b/src/app/core/services/bootstrap-request-timeout.ts @@ -0,0 +1,9 @@ +import { InjectionToken } from '@angular/core'; + +export const BOOTSTRAP_REQUEST_TIMEOUT_MS = new InjectionToken( + 'BOOTSTRAP_REQUEST_TIMEOUT_MS', + { + providedIn: 'root', + factory: () => 10_000, + }, +); diff --git a/src/app/core/services/tenant.service.spec.ts b/src/app/core/services/tenant.service.spec.ts index 26fa838..744a6b3 100644 --- a/src/app/core/services/tenant.service.spec.ts +++ b/src/app/core/services/tenant.service.spec.ts @@ -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(); + } + }); }); diff --git a/src/app/core/services/tenant.service.ts b/src/app/core/services/tenant.service.ts index c87bfd2..28fdf05 100644 --- a/src/app/core/services/tenant.service.ts +++ b/src/app/core/services/tenant.service.ts @@ -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(null); private readonly statusState = signal('idle'); @@ -48,7 +50,6 @@ export class TenantService extends BaseApiService { return `${environment.url}tenants/${tenant.codigo}`; } - async bootstrap(): Promise { 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( - `${environment.url}tenants/bootstrap/${domain}` - ) + this.http + .get(`${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);