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
93 changes: 93 additions & 0 deletions src/app/shared/html-content.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { HtmlContentService } from './html-content.service';
import { LocaleService } from '../core/locale/locale.service';

describe('HtmlContentService', () => {
let service: HtmlContentService;
let httpClient: jasmine.SpyObj<HttpClient>;
let localeService: jasmine.SpyObj<LocaleService>;

beforeEach(() => {
const httpSpy = jasmine.createSpyObj('HttpClient', ['get']);
const localeSpy = jasmine.createSpyObj('LocaleService', ['getCurrentLanguageCode']);

TestBed.configureTestingModule({
providers: [
HtmlContentService,
{ provide: HttpClient, useValue: httpSpy },
{ provide: LocaleService, useValue: localeSpy }
]
});

service = TestBed.inject(HtmlContentService);
httpClient = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
localeService = TestBed.inject(LocaleService) as jasmine.SpyObj<LocaleService>;
});

it('should be created', () => {
expect(service).toBeTruthy();
});

describe('getHmtlContentByPathAndLocale - fallback mechanism', () => {
it('should return English fallback when Czech translation not found (404)', async () => {
localeService.getCurrentLanguageCode.and.returnValue('cs');

const czechContent404 = new HttpResponse({ status: 404, body: '' });
const englishContent200 = new HttpResponse({ status: 200, body: '<div>English Content</div>' });

httpClient.get.and.returnValues(
of(czechContent404),
of(czechContent404),
of(englishContent200)
);

const result = await service.getHmtlContentByPathAndLocale('license');

expect(result).toBe('<div>English Content</div>');
expect(httpClient.get).toHaveBeenCalledTimes(3);
});

it('should return localized content when translation exists (200)', async () => {
localeService.getCurrentLanguageCode.and.returnValue('cs');

const czechContent200 = new HttpResponse({ status: 200, body: '<div>Czech Content</div>' });

httpClient.get.and.returnValue(of(czechContent200));

const result = await service.getHmtlContentByPathAndLocale('license');

expect(result).toBe('<div>Czech Content</div>');
expect(httpClient.get).toHaveBeenCalledTimes(1);
});

it('should return English content directly when language is "en"', async () => {
localeService.getCurrentLanguageCode.and.returnValue('en');

const englishContent200 = new HttpResponse({ status: 200, body: '<div>English Content</div>' });

httpClient.get.and.returnValue(of(englishContent200));

const result = await service.getHmtlContentByPathAndLocale('license');

expect(result).toBe('<div>English Content</div>');
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get.calls.mostRecent().args[0]).toContain('static-files/license.html');
expect(httpClient.get.calls.mostRecent().args[0]).not.toContain('/cs/');
});

it('should return undefined when both Czech and English files not found', async () => {
localeService.getCurrentLanguageCode.and.returnValue('cs');

const content404 = new HttpResponse({ status: 404, body: '' });

httpClient.get.and.returnValue(of(content404));

const result = await service.getHmtlContentByPathAndLocale('nonexistent');

expect(result).toBeUndefined();
expect(httpClient.get).toHaveBeenCalledTimes(4);
});
});
});
59 changes: 49 additions & 10 deletions src/app/shared/html-content.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Injectable } 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';

/**
Expand All @@ -15,13 +15,50 @@ export class HtmlContentService {
private localeService: LocaleService,) {}

/**
* 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) {
// catchError -> return empty value.
return this.http.get(url, { responseType: 'text' }).pipe(
catchError(() => observableOf('')));
return this.http.get(url, { responseType: 'text', observe: 'response' }).pipe(
catchError((error) => observableOf(new HttpResponse({ status: error.status || 0, body: '' }))));
}

/**
* Append a cache-busting query parameter to force a fresh response.
* @param url file location
*/
private appendCacheBust(url: string): string {
const cacheBustParam = 'cacheBust=';
if (url.includes(cacheBustParam)) {
return url;
}
const separator = url.includes('?') ? '&' : '?';
const cacheBustValue = Math.floor(Date.now() / 3600000).toString();
return `${url}${separator}${cacheBustParam}${cacheBustValue}`;
}

/**
* Load HTML content 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 === 404) {
const refreshed = await firstValueFrom(this.fetchHtmlContent(this.appendCacheBust(url)));
if (refreshed.status === 404) {
return undefined;
}
if (refreshed.status === 200) {
return refreshed.body ?? '';
}
}
if (response.status === 200) {
return response.body ?? '';
}
if (response.status === 304) {
return response.body ?? '';
}
return undefined;
}

/**
Expand All @@ -40,15 +77,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'">
<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>
54 changes: 51 additions & 3 deletions src/app/static-page/static-page.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ 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) {
async function setupTest(html: string | undefined, restBase?: string) {
const htmlContentService = jasmine.createSpyObj('htmlContentService', {
fetchHtmlContent: of(html),
getHmtlContentByPathAndLocale: Promise.resolve(html)
});

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

const appConfig = {
...environment,
ui: {
Expand All @@ -37,21 +42,21 @@ describe('StaticPageComponent', () => {
providers: [
{ provide: HtmlContentService, useValue: htmlContentService },
{ provide: Router, useValue: new RouterMock() },
{ 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();
Expand Down Expand Up @@ -101,4 +106,47 @@ describe('StaticPageComponent', () => {

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();
});
});

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();
});
});
});
69 changes: 37 additions & 32 deletions src/app/static-page/static-page.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Component, Inject, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core';
import { HtmlContentService } from '../shared/html-content.service';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { Router } from '@angular/router';
import { isEmpty, isNotEmpty } from '../shared/empty.util';
import { STATIC_FILES_DEFAULT_ERROR_PAGE_PATH, STATIC_PAGE_PATH } from './static-page-routing-paths';
import { isEmpty } from '../shared/empty.util';
import { STATIC_PAGE_PATH } from './static-page-routing-paths';
import { APP_CONFIG, AppConfig } from '../../config/app-config.interface';
import { ServerResponseService } from '../core/services/server-response.service';

/**
* Component which load and show static files from the `static-files` folder.
Expand All @@ -19,27 +20,45 @@ export class StaticPageComponent implements OnInit {
static readonly no_static: string = 'no_static_';
htmlContent: BehaviorSubject<string> = new BehaviorSubject<string>('');
htmlFileName: string;
contentState: 'loading' | 'found' | 'not-found' = 'loading';

constructor(private htmlContentService: HtmlContentService,
private router: Router,
private responseService: ServerResponseService,
private changeDetector: ChangeDetectorRef,
@Inject(APP_CONFIG) protected appConfig?: AppConfig) { }

async ngOnInit(): Promise<void> {
// Fetch html file name from the url path. `static/some_file.html`
this.htmlFileName = this.getHtmlFileName();

let htmlContent = await this.htmlContentService.getHmtlContentByPathAndLocale(this.htmlFileName);
if (isNotEmpty(htmlContent)) {
const restBase = this.appConfig?.rest?.baseUrl;
const oaiUrl = restBase ? new URL('/server/oai', restBase).href : '/server/oai';
htmlContent = htmlContent.replace(/href="\/server\/oai/gi, 'href="' + oaiUrl);

this.htmlContent.next(htmlContent);
return;
try {
// Fetch html file name from the url path. `static/some_file.html`
this.htmlFileName = this.getHtmlFileName();

let htmlContent = await this.htmlContentService.getHmtlContentByPathAndLocale(this.htmlFileName);
if (htmlContent !== undefined) {
const restBase = this.appConfig?.rest?.baseUrl;
const oaiUrl = restBase ? new URL('/server/oai', restBase).href : '/server/oai';
htmlContent = htmlContent.replace(/href="\/server\/oai/gi, 'href="' + oaiUrl);

this.htmlContent.next(htmlContent);
this.contentState = 'found';
this.changeDetector.detectChanges();
return;
}

// Content not found - set 404 status for SSR and show inline error
this.responseService.setNotFound();
this.contentState = 'not-found';
this.changeDetector.detectChanges();
} catch (error) {
console.error('Static page load error:', {
fileName: this.htmlFileName,
url: this.router.url,
error: error
});
this.responseService.setNotFound();
this.contentState = 'not-found';
this.changeDetector.detectChanges();
}

// Show error page
await this.loadErrorPage();
}

/**
Expand Down Expand Up @@ -123,24 +142,10 @@ export class StaticPageComponent implements OnInit {
urlInList = urlInList.filter(n => n);
// if length is 1 - html file name wasn't defined.
if (isEmpty(urlInList) || urlInList.length === 1) {
void this.loadErrorPage();
return null;
}

// If the url is too long take just the first string after `/static` prefix.
return urlInList[1]?.split('#')?.[0];
}

/**
* Load `static-files/error.html`
* @private
*/
private async loadErrorPage() {
let errorPage = await firstValueFrom(this.htmlContentService.fetchHtmlContent(STATIC_FILES_DEFAULT_ERROR_PAGE_PATH));
if (isEmpty(errorPage)) {
console.error('Cannot load error page from the path: ' + STATIC_FILES_DEFAULT_ERROR_PAGE_PATH);
return;
}
this.htmlContent.next(errorPage);
}
}
Loading