// Load the JIT compiler BEFORE any Angular router/common imports 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 the request handler logic here. vi.mock('@angular/ssr/node', () => ({ AngularNodeAppEngine: class {}, createNodeRequestHandler: (fn: Function) => fn, isMainModule: () => false, writeResponseToNodeResponse: vi.fn(), })); vi.mock('node:http', () => ({ default: { createServer: vi.fn() }, })); vi.mock('node:fs', () => ({ default: { readFileSync: vi.fn(() => 'static shell') }, readFileSync: vi.fn(() => 'static shell'), })); vi.mock('node:path', () => ({ default: { join: (...args: string[]) => args.join('/'), dirname: () => '/app/server' }, join: (...args: string[]) => args.join('/'), dirname: () => '/app/server', })); // Mock the engine manifest that only exists after ng build vi.mock('./angular-app-engine-manifest.mjs', () => ({})); describe('SSR server — server.ts', () => { it('should export a request handler', async () => { const mod = await import('./server'); expect(mod.requestHandler).toBeDefined(); }); it('should have spider detection imports and serveStaticShell', async () => { const fs = require('fs'); const content = fs.readFileSync('./server.ts', 'utf-8'); expect(content).toContain('isSpider'); expect(content).toContain('getUserAgent'); expect(content).toContain('serveStaticShell'); expect(content).toContain('spider_detection'); }); it('should gate SSR behind spider detection', async () => { const fs = require('fs'); const content = fs.readFileSync('./server.ts', 'utf-8'); // Verify the spider detection gate exists expect(content).toContain("if (!isSpider(userAgent))"); expect(content).toContain('serveStaticShell(res)'); }); }); describe('spider_detection.ts — TypeScript spider detection', () => { it('should detect Googlebot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)')).toBe(true); }); it('should detect Bingbot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)')).toBe(true); }); it('should detect DuckDuckBot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)')).toBe(true); }); it('should detect YandexBot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)')).toBe(true); }); it('should detect Baiduspider', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)')).toBe(true); }); it('should detect Applebot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (compatible; Applebot/0.1; +https://www.apple.com/go/applebot)')).toBe(true); }); it('should detect Facebook bot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)')).toBe(true); }); it('should detect Twitterbot', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Twitterbot/1.0')).toBe(true); }); it('should NOT detect Chrome', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')).toBe(false); }); it('should NOT detect Firefox', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0')).toBe(false); }); it('should NOT detect Safari', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15')).toBe(false); }); it('should NOT detect Edge', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91')).toBe(false); }); it('should NOT detect mobile Safari', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1')).toBe(false); }); it('should handle empty string', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('')).toBe(false); }); it('should handle null/undefined', async () => { const { isSpider } = await import('./spider_detection'); expect(isSpider('')).toBe(false); }); it('isSearchSpider should distinguish search from social bots', async () => { const { isSearchSpider, isSpider } = await import('./spider_detection'); expect(isSpider('facebookexternalhit/1.1')).toBe(true); expect(isSearchSpider('facebookexternalhit/1.1')).toBe(false); expect(isSearchSpider('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(true); }); it('getSpiderInfo should return SpiderInfo for known bots', async () => { const { getSpiderInfo } = await import('./spider_detection'); const info = getSpiderInfo('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'); expect(info).not.toBeNull(); expect(info?.engine).toBe('Google'); expect(info?.category).toBe('search'); }); it('getSpiderInfo should return null for browsers', async () => { const { getSpiderInfo } = await import('./spider_detection'); expect(getSpiderInfo('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0')).toBeNull(); }); it('getUserAgent should extract UA from request headers', async () => { const { getUserAgent } = await import('./spider_detection'); const headers: Record = { 'user-agent': 'TestBot/1.0', 'host': 'example.com', }; expect(getUserAgent(headers)).toBe('TestBot/1.0'); }); it('getUserAgent should handle missing UA header', async () => { const { getUserAgent } = await import('./spider_detection'); expect(getUserAgent({ host: 'example.com' })).toBe(''); }); }); describe('app.server.routes.ts — RenderMode configuration', () => { it('should use RenderMode.Server for all routes (SSR server handles routing)', async () => { const fs = require('fs'); const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8'); // Should have RenderMode.Server entries const serverMatches = content.match(/RenderMode\.Server/g); expect(serverMatches).not.toBeNull(); expect(serverMatches.length).toBeGreaterThanOrEqual(7); }); 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"); }); });