Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/app/shared/html-content.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('HtmlContentService', () => {
expect(content).toBe('Localized content');
});

it('should fallback from locale-specific to default namespaced URL when localized content is empty', fakeAsync(() => {
it('should fallback from locale-specific to default namespaced URL when localized content is missing', fakeAsync(() => {
setup('/repository/');
localeService.languageCode = 'cs';

Expand All @@ -83,16 +83,35 @@ describe('HtmlContentService', () => {
});

const localizedRequest = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html');
localizedRequest.flush('');
localizedRequest.flush('Not Found', { status: 404, statusText: 'Not Found' });
tick();

const fallbackRequest = httpMock.expectOne('/repository/static-files/license-ud-1.0');
const fallbackRequest = httpMock.expectOne('/repository/static-files/license-ud-1.0.html');
fallbackRequest.flush('Fallback content');
tick();

expect(content).toBe('Fallback content');
}));

it('should fallback from locale-specific to default URL when locale returns 404', fakeAsync(() => {
setup('/');
localeService.languageCode = 'cs';

let content: string | undefined;
service.getHmtlContentByPathAndLocale('license').then((result) => {
content = result;
});

httpMock.expectOne('/static-files/cs/license.html')
.flush('Not Found', { status: 404, statusText: 'Not Found' });
tick();

httpMock.expectOne('/static-files/license.html').flush('<div>English Content</div>');
tick();

expect(content).toBe('<div>English Content</div>');
}));

it('should return empty string from getHtmlContent when request fails', async () => {
setup('/repository');

Expand Down
36 changes: 28 additions & 8 deletions src/app/shared/html-content.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { isPlatformServer } from '@angular/common';
import { Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { firstValueFrom, of as observableOf } from 'rxjs';
import { HTML_SUFFIX, STATIC_FILES_PROJECT_PATH } from '../static-page/static-page-routing-paths';
import { isEmpty, isNotEmpty } from './empty.util';
import { isEmpty } from './empty.util';
import { LocaleService } from '../core/locale/locale.service';
import { APP_CONFIG, AppConfig } from '../../config/app-config.interface';
import { REQUEST } from '@nguniversal/express-engine/tokens';
Expand Down Expand Up @@ -66,11 +66,29 @@ export class HtmlContentService {
}

/**
* Load `.html` file content or return empty string if an error.
* Load `.html` file content and return the full response.
* @param url file location
*/
fetchHtmlContent(url: string) {
return this.getHtmlContent(url);
const namespacedUrl = this.composeNamespacedUrl(url);
const runtimeUrl = this.buildRuntimeUrl(namespacedUrl);
return this.http.get(runtimeUrl, { responseType: 'text', observe: 'response' }).pipe(
catchError((error) => observableOf(new HttpResponse({ status: error.status || 0, body: '' }))));
}

/**
* Load HTML content for a single URL attempt and handle cached 304 responses.
* @param url file location
*/
private async loadHtmlContent(url: string): Promise<string | undefined> {
const response = await firstValueFrom(this.fetchHtmlContent(url));
if (response.status === 200) {
return response.body ?? '';
}
if (response.status === 304) {
return response.body ?? '';
}
return undefined;
}

/**
Expand All @@ -89,15 +107,17 @@ export class HtmlContentService {
url += isEmpty(language) ? '/' + fileName : '/' + language + '/' + fileName;
// Add `.html` suffix to get the current html file
url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX;
let potentialContent = await firstValueFrom(this.fetchHtmlContent(url));
if (isNotEmpty(potentialContent)) {
let potentialContent = await this.loadHtmlContent(url);
if (potentialContent !== undefined) {
return potentialContent;
}

// If the file wasn't find, get the non-translated file from the default package.
url = STATIC_FILES_PROJECT_PATH + '/' + fileName;
potentialContent = await firstValueFrom(this.fetchHtmlContent(url));
if (isNotEmpty(potentialContent)) {
// Add `.html` suffix to match localized request behavior
url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX;
potentialContent = await this.loadHtmlContent(url);
if (potentialContent !== undefined) {
return potentialContent;
}
}
Expand Down
19 changes: 18 additions & 1 deletion src/app/static-page/static-page.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
<div class="container" >
<div class="container text-center my-5" *ngIf="contentState === 'loading'">
<ds-themed-loading [spinner]="true" [showMessage]="false"></ds-themed-loading>
</div>

<!-- Show static page content when found -->
<div class="container" *ngIf="contentState === 'found'">
<div [innerHTML]="(htmlContent | async) | dsSafeHtml" (click)="processLinks($event)"></div>
</div>

<!-- Show 404 error when content not found (matches PageNotFoundComponent design) -->
<div class="container page-not-found" *ngIf="contentState === 'not-found' && !(htmlContent | async)">
<h1>404</h1>
<h2><small>{{"static-page.404.page-not-found" | translate}}</small></h2>
<br/>
<p>{{"static-page.404.help" | translate}}</p>
<br/>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{"static-page.404.link.home-page" | translate}}</a>
</p>
</div>
122 changes: 113 additions & 9 deletions src/app/static-page/static-page.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,32 @@ import { of } from 'rxjs';
import { APP_CONFIG } from '../../config/app-config.interface';
import { environment } from '../../environments/environment';
import { ClarinSafeHtmlPipe } from '../shared/utils/clarin-safehtml.pipe';
import { ServerResponseService } from '../core/services/server-response.service';

describe('StaticPageComponent', () => {
async function setupTest(html: string, restBase?: string, route: string = '/static/test-file.html') {
function createDeferred<T>() {
let resolve: (value: T) => void;
let reject: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve: resolve!, reject: reject! };
}

async function setupTest(
html: string | undefined,
restBase?: string,
contentPromise?: Promise<string | undefined>,
route: string = '/static/test-file.html'
) {
const htmlContentService = jasmine.createSpyObj('htmlContentService', {
fetchHtmlContent: of(html),
getHmtlContentByPathAndLocale: Promise.resolve(html)
getHmtlContentByPathAndLocale: contentPromise ?? Promise.resolve(html)
});

const responseService = jasmine.createSpyObj('responseService', {
setNotFound: null
});

const router = new RouterMock();
Expand All @@ -40,29 +60,29 @@ describe('StaticPageComponent', () => {
providers: [
{ provide: HtmlContentService, useValue: htmlContentService },
{ provide: Router, useValue: router },
{ provide: ServerResponseService, useValue: responseService },
{ provide: APP_CONFIG, useValue: appConfig }
]
}).compileComponents();

const fixture = TestBed.createComponent(StaticPageComponent);
const component = fixture.componentInstance;
return { fixture, component, htmlContentService };
return { fixture, component, htmlContentService, responseService };
}

it('should create', async () => {
const { component } = await setupTest('<div>test</div>');
expect(component).toBeTruthy();
});

// Load `TEST MESSAGE`
it('should load html file content', async () => {
const { component } = await setupTest('<div id="idShouldNotBeRemoved">TEST MESSAGE</div>');
await component.ngOnInit();
expect(component.htmlContent.value).toBe('<div id="idShouldNotBeRemoved">TEST MESSAGE</div>');
});

it('should call HtmlContentService with the route html file name', async () => {
const { component, htmlContentService } = await setupTest('<div>TEST MESSAGE</div>', undefined, '/static/license-ud-1.0.html');
const { component, htmlContentService } = await setupTest('<div>TEST MESSAGE</div>', undefined, undefined, '/static/license-ud-1.0.html');
await component.ngOnInit();

expect(htmlContentService.getHmtlContentByPathAndLocale).toHaveBeenCalledWith('license-ud-1.0.html');
Expand All @@ -72,7 +92,8 @@ describe('StaticPageComponent', () => {
const oaiHtml = '<a href="/server/oai/request?verb=ListSets">OAI</a>';
const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/rest');

await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

const rewritten = 'https://api.example.org/server/oai/request?verb=ListSets';
Expand All @@ -85,7 +106,8 @@ describe('StaticPageComponent', () => {
const oaiHtml = '<a href="/server/oai/request?verb=Identify">OAI</a>';
const { fixture, component } = await setupTest(oaiHtml, undefined);

await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

expect(component.htmlContent.value).toContain('/server/oai/request?verb=Identify');
Expand All @@ -95,7 +117,8 @@ describe('StaticPageComponent', () => {
const oaiHtml = '<a href="/server/oai/request?verb=ListRecords">OAI</a>';
const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/rest/');

await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

expect(component.htmlContent.value).toContain('https://api.example.org/server/oai/request?verb=ListRecords');
Expand All @@ -106,9 +129,90 @@ describe('StaticPageComponent', () => {
const otherHtml = '<a href="/server/other">Other</a>';
const { fixture, component } = await setupTest(otherHtml, 'https://api.example.org/rest');

await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

expect(component.htmlContent.value).toBe(otherHtml);
});

describe('contentState behavior', () => {
it('should initialize contentState to "loading"', async () => {
const { component } = await setupTest('<div>test</div>');
expect(component.contentState).toBe('loading');
});

it('should set contentState to "found" when content loads successfully', async () => {
const { component } = await setupTest('<div>Test Content</div>');
await component.ngOnInit();
expect(component.contentState).toBe('found');
});

it('should set contentState to "not-found" when content is undefined', async () => {
const { component, responseService } = await setupTest(undefined);

await component.ngOnInit();

expect(component.contentState).toBe('not-found');
expect(responseService.setNotFound).toHaveBeenCalled();
});

it('should keep loading state and not render 404 before content promise resolves', async () => {
const deferred = createDeferred<string | undefined>();
const { fixture, component } = await setupTest(undefined, undefined, deferred.promise);

const initPromise = component.ngOnInit();
fixture.detectChanges();

expect(component.contentState).toBe('loading');
expect(component.htmlContent.value).toBe('');
expect(fixture.nativeElement.querySelector('.page-not-found')).toBeNull();

deferred.resolve('<div>Loaded later</div>');
await initPromise;
fixture.detectChanges();

expect(component.contentState).toBe('found');
expect(fixture.nativeElement.querySelector('.page-not-found')).toBeNull();
});

it('should reset stale not-found state to loading on init', async () => {
const deferred = createDeferred<string | undefined>();
const { component } = await setupTest(undefined, undefined, deferred.promise);

component.contentState = 'not-found';
component.htmlContent.next('<div>stale</div>');

const initPromise = component.ngOnInit();

expect(component.contentState).toBe('loading');
expect(component.htmlContent.value).toBe('');

deferred.resolve('<div>fresh</div>');
await initPromise;

expect(component.contentState).toBe('found');
});
});

describe('change detection', () => {
it('should call changeDetector.detectChanges() after successful content load', async () => {
const { component } = await setupTest('<div>test</div>');
spyOn((component as any).changeDetector, 'detectChanges');

await component.ngOnInit();

expect((component as any).changeDetector.detectChanges).toHaveBeenCalled();
});

it('should call changeDetector.detectChanges() when content not found', async () => {
const { component } = await setupTest(undefined);

spyOn((component as any).changeDetector, 'detectChanges');

await component.ngOnInit();

expect((component as any).changeDetector.detectChanges).toHaveBeenCalled();
});
});
});
Loading
Loading