fix: ensure SSR always fetches fresh assets from database
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Failing after 27s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 5m24s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Failing after 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Failing after 27s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 5m24s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Failing after 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Root cause: Dockerfile copied only browser/ to nginx; server/server.mjs was built but never deployed or executed. Prerender baked DEFAULT_CONFIG into static HTML with no API at build time, causing stale content on every initial paint. Changes: - Dockerfile: serve stage uses node:22-alpine, installs nginx via apk, copies both browser/ and server/ bundles from build stage - docker-entrypoint.sh: starts Angular SSR Node process on port 4200, waits for readiness, exports SSR_API_BASE_URL, then exec nginx - nginx.conf.template: adds upstream ssr_backend block (127.0.0.1:4200), proxies / to SSR backend, static assets served directly with 1y cache - app.server.routes.ts: all routes switched from RenderMode.Prerender to RenderMode.Server for zero stale content - server.ts: removed dead prerender code (PRERENDERED_ROUTES, isPrerenderedRoute, allowStaticRouteRender) - main.server.ts: injects SSR-aware apiBaseUrl via APP_CONFIG (reads SSR_API_BASE_URL env var) - server.spec.ts: updated 5 test suites + added 5 new suites (31 tests) covering server.ts cleanup, RenderMode.Server, entrypoint lifecycle, Dockerfile build, nginx upstream proxy, and API URL injection - vitest.config.ts: added server.spec.ts to include pattern Request flow: Browser -> nginx (port 80) -> SSR server (port 4200) -> Angular SSR -> HttpClient -> localhost:80/api/station-config -> nginx proxies to api:8000 -> fresh data rendered in HTML
This commit is contained in:
1 parent
2ece38e52d
commit
328d067d97
9 files changed
+243
-146
No files matched your search
+152
-67
@@ -4,7 +4,7 @@ import '@angular/compiler';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the @angular/ssr/node imports that pull in Node.js http server code
|
||||
// — we only want to test PRERENDERED_ROUTES and isPrerenderedRoute here.
|
||||
// — we only want to test the request handler logic here.
|
||||
vi.mock('@angular/ssr/node', () => ({
|
||||
AngularNodeAppEngine: class {},
|
||||
createNodeRequestHandler: (fn: Function) => fn,
|
||||
@@ -16,75 +16,160 @@ vi.mock('node:http', () => ({
|
||||
default: { createServer: vi.fn() },
|
||||
}));
|
||||
|
||||
import { PRERENDERED_ROUTES, isPrerenderedRoute } from './server';
|
||||
|
||||
describe('SSR server — server.ts', () => {
|
||||
describe('PRERENDERED_ROUTES', () => {
|
||||
it('should contain exactly 5 routes', () => {
|
||||
expect(PRERENDERED_ROUTES).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should include the home route', () => {
|
||||
expect(PRERENDERED_ROUTES).toContain('/');
|
||||
});
|
||||
|
||||
it('should include /about', () => {
|
||||
expect(PRERENDERED_ROUTES).toContain('/about');
|
||||
});
|
||||
|
||||
it('should include /schedule', () => {
|
||||
expect(PRERENDERED_ROUTES).toContain('/schedule');
|
||||
});
|
||||
|
||||
it('should include /events', () => {
|
||||
expect(PRERENDERED_ROUTES).toContain('/events');
|
||||
});
|
||||
|
||||
it('should include /contact', () => {
|
||||
expect(PRERENDERED_ROUTES).toContain('/contact');
|
||||
});
|
||||
it('should export a default request handler', async () => {
|
||||
const mod = await import('./server');
|
||||
expect(mod.default).toBeDefined();
|
||||
});
|
||||
|
||||
describe('isPrerenderedRoute()', () => {
|
||||
it('should match exact prerendered paths', () => {
|
||||
expect(isPrerenderedRoute('/')).toBe(true);
|
||||
expect(isPrerenderedRoute('/about')).toBe(true);
|
||||
expect(isPrerenderedRoute('/schedule')).toBe(true);
|
||||
expect(isPrerenderedRoute('/events')).toBe(true);
|
||||
expect(isPrerenderedRoute('/contact')).toBe(true);
|
||||
});
|
||||
it('should not export PRERENDERED_ROUTES (all routes now use RenderMode.Server)', async () => {
|
||||
const mod = await import('./server');
|
||||
// The server.ts file no longer exports PRERENDERED_ROUTES or isPrerenderedRoute
|
||||
// because all routes use on-demand SSR. Verify the file doesn't reference them.
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).not.toContain('PRERENDERED_ROUTES');
|
||||
expect(content).not.toContain('isPrerenderedRoute');
|
||||
expect(content).not.toContain('AngularAppEngine');
|
||||
});
|
||||
|
||||
it('should match paths with trailing slashes', () => {
|
||||
expect(isPrerenderedRoute('/about/')).toBe(true);
|
||||
expect(isPrerenderedRoute('/schedule/')).toBe(true);
|
||||
expect(isPrerenderedRoute('/events/')).toBe(true);
|
||||
expect(isPrerenderedRoute('/contact/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match paths with query strings', () => {
|
||||
expect(isPrerenderedRoute('/about?foo=bar')).toBe(true);
|
||||
expect(isPrerenderedRoute('/schedule?day=monday')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match paths with fragments', () => {
|
||||
expect(isPrerenderedRoute('/events#upcoming')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-prerendered routes', () => {
|
||||
expect(isPrerenderedRoute('/login')).toBe(false);
|
||||
expect(isPrerenderedRoute('/admin')).toBe(false);
|
||||
expect(isPrerenderedRoute('/nonexistent')).toBe(false);
|
||||
expect(isPrerenderedRoute('/admin/dashboard')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle full URLs', () => {
|
||||
expect(isPrerenderedRoute('https://kmtnflower.org/about')).toBe(true);
|
||||
expect(isPrerenderedRoute('http://localhost:4200/events')).toBe(true);
|
||||
expect(isPrerenderedRoute('https://kmtnflower.org/login')).toBe(false);
|
||||
});
|
||||
|
||||
it('should normalise root path — "/" stays "/"', () => {
|
||||
expect(isPrerenderedRoute('/')).toBe(true);
|
||||
});
|
||||
it('should have a clean server with lazy engine bootstrapping', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('getEngine');
|
||||
expect(content).toContain('AngularNodeAppEngine');
|
||||
expect(content).toContain('listen(4200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.server.routes.ts — RenderMode.Server for all routes', () => {
|
||||
it('should use RenderMode.Server for all public routes (fresh data on every request)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||
|
||||
// Every route should use RenderMode.Server
|
||||
const prerenderMatches = content.match(/RenderMode\.Prerender/g);
|
||||
expect(prerenderMatches).toBeNull();
|
||||
|
||||
// Should have RenderMode.Server entries
|
||||
const serverMatches = content.match(/RenderMode\.Server/g);
|
||||
expect(serverMatches).not.toBeNull();
|
||||
expect(serverMatches.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('should have no Prerender routes (eliminates stale baked-in DEFAULT_CONFIG)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||
expect(content).not.toContain('RenderMode.Prerender');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker-entrypoint.sh — SSR + nginx lifecycle', () => {
|
||||
it('should be executable', () => {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync('./docker-entrypoint.sh');
|
||||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('should start the SSR node process before nginx', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
|
||||
// SSR must start before nginx
|
||||
const ssrStart = content.indexOf('node /app/server/server.mjs');
|
||||
const nginxStart = content.indexOf('exec nginx');
|
||||
expect(ssrStart).toBeGreaterThan(-1);
|
||||
expect(nginxStart).toBeGreaterThan(-1);
|
||||
expect(ssrStart).toBeLessThan(nginxStart);
|
||||
});
|
||||
|
||||
it('should set SSR_API_BASE_URL for the SSR server', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
|
||||
expect(content).toContain('SSR_API_BASE_URL');
|
||||
expect(content).toContain('http://localhost:80');
|
||||
});
|
||||
|
||||
it('should wait for SSR server readiness before starting nginx', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
|
||||
expect(content).toContain('Waiting for SSR server to be ready');
|
||||
expect(content).toContain('localhost:4200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dockerfile — multi-stage build with SSR server', () => {
|
||||
it('should copy the server bundle in addition to browser output', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
expect(content).toMatch(/COPY.*server.*\/app\/server/);
|
||||
});
|
||||
|
||||
it('should use a Node.js base image for the serve stage', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
// Second FROM should be Node-based (not plain nginx)
|
||||
const fromLines = content.split('\n').filter(l => l.startsWith('FROM '));
|
||||
expect(fromLines.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fromLines[1]).toContain('node');
|
||||
});
|
||||
|
||||
it('should install nginx in the serve stage', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
expect(content).toContain('apk add --no-cache nginx');
|
||||
});
|
||||
|
||||
it('should expose port 80', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
expect(content).toContain('EXPOSE 80');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nginx.conf.template — SSR proxy chain', () => {
|
||||
it('should define an upstream pointing to SSR server on port 4200', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('upstream ssr_backend');
|
||||
expect(content).toContain('127.0.0.1:4200');
|
||||
});
|
||||
|
||||
it('should proxy page requests to the SSR backend', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('proxy_pass http://ssr_backend');
|
||||
});
|
||||
|
||||
it('should serve static assets directly from disk with long cache', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('location ~*');
|
||||
expect(content).toContain('.js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map');
|
||||
expect(content).toContain('expires 1y');
|
||||
expect(content).toContain('public, immutable');
|
||||
});
|
||||
|
||||
it('should still proxy /api/ to the upstream backend', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('location /api/');
|
||||
expect(content).toContain('__API_UPSTREAM__');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main.server.ts — SSR API base URL injection', () => {
|
||||
it('should override APP_CONFIG with SSR-aware apiBaseUrl', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
|
||||
expect(content).toContain('SSR_API_BASE_URL');
|
||||
expect(content).toContain('ssrAppConfig');
|
||||
expect(content).toContain('provide: APP_CONFIG');
|
||||
});
|
||||
|
||||
it('should read SSR_API_BASE_URL from environment with fallback', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
|
||||
expect(content).toContain("process.env.SSR_API_BASE_URL");
|
||||
expect(content).toContain("http://localhost:80");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user