/** @jest-environment node */ import { jest } from '@jest/globals'; import { load } from 'cheerio'; const mockBunFetch = jest.fn(); const mockReadFileSync = jest.fn(); jest.mock('pino', () => () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), })); jest.mock('fs', () => ({ ...jest.requireActual('fs'), readFileSync: (...args: unknown[]) => mockReadFileSync(...args), })); jest.mock( 'bun', () => ({ fetch: (...args: unknown[]) => mockBunFetch(...args), }), { virtual: true } ); describe('deploy/server', () => { const htmlTemplate = ` AppFlowy
`; let createServer: typeof import('./server').createServer; let start: typeof import('./server').start; const makeRequest = (path: string, init: RequestInit = {}) => { const headers = new Headers(init.headers); headers.set('host', 'appflowy.test'); return new Request(`https://appflowy.test${path}`, { ...init, method: init.method ?? 'GET', headers, }); }; const getHtml = async (response: Response) => await response.text(); const extractPublishError = (html: string) => { const $ = load(html); const scriptContent = $('#appflowy-publish-error').html(); if (!scriptContent) return undefined; const match = scriptContent.match(/window.__APPFLOWY_PUBLISH_ERROR__ = (.*);/); if (!match) return undefined; return JSON.parse(match[1]); }; beforeAll(async () => { process.env.NODE_ENV = 'test'; process.env.APPFLOWY_BASE_URL = 'https://api.example.com'; const globalAny = global as typeof globalThis & { btoa?: (value: string) => string }; if (!globalAny.btoa) { globalAny.btoa = (value: string) => Buffer.from(value, 'binary').toString('base64'); } ({ createServer, start } = await import('./server')); }); beforeEach(() => { mockBunFetch.mockReset(); mockReadFileSync.mockReset(); mockReadFileSync.mockReturnValue(htmlTemplate); }); it('redirects "/" to /app without hitting the API', async () => { const response = await createServer(makeRequest('/')); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('/app'); expect(mockBunFetch).not.toHaveBeenCalled(); expect(mockReadFileSync).not.toHaveBeenCalled(); }); it('renders marketing routes with custom metadata', async () => { const response = await createServer(makeRequest('/login')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toBe('Login | AppFlowy'); expect($('meta[name="description"]').attr('content')).toBe('Login to AppFlowy'); expect(mockBunFetch).not.toHaveBeenCalled(); expect(mockReadFileSync).toHaveBeenCalled(); }); it('renders /after-payment route with payment metadata', async () => { const response = await createServer(makeRequest('/after-payment')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toBe('Payment Success | AppFlowy'); expect($('meta[name="description"]').attr('content')).toBe('Payment success on AppFlowy'); }); it('renders /app route without custom metadata', async () => { const response = await createServer(makeRequest('/app')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toBe('AppFlowy'); expect(mockBunFetch).not.toHaveBeenCalled(); }); it('renders /as-template route', async () => { const response = await createServer(makeRequest('/as-template')); expect(response.status).toBe(200); expect(mockBunFetch).not.toHaveBeenCalled(); }); it('renders /accept-invitation route', async () => { const response = await createServer(makeRequest('/accept-invitation')); expect(response.status).toBe(200); expect(mockBunFetch).not.toHaveBeenCalled(); }); it('renders /import route', async () => { const response = await createServer(makeRequest('/import')); expect(response.status).toBe(200); expect(mockBunFetch).not.toHaveBeenCalled(); }); it('renders sub-paths of marketing routes like /app/workspace', async () => { const response = await createServer(makeRequest('/app/workspace/123')); expect(response.status).toBe(200); expect(mockBunFetch).not.toHaveBeenCalled(); }); it('redirects namespace-only requests when publish info exists', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ data: { info: { namespace: 'space', publish_name: 'hello world' } }, }), }); const response = await createServer(makeRequest('/space')); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('/space/hello%20world'); expect(mockBunFetch).toHaveBeenCalledWith( 'https://api.example.com/api/workspace/published/space', { verbose: false } ); expect(mockReadFileSync).not.toHaveBeenCalled(); }); it('re-encodes namespace when fetching metadata for namespace routes', async () => { const namespace = 'space slug'; const encodedNamespace = encodeURIComponent(namespace); mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ data: { info: { namespace, publish_name: 'home page' } }, }), }); const response = await createServer(makeRequest(`/${encodedNamespace}`)); expect(response.status).toBe(302); expect(mockBunFetch).toHaveBeenCalledWith( `https://api.example.com/api/workspace/published/${encodedNamespace}`, { verbose: false } ); }); it('injects error payload when namespace has no default publish page', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ data: {} }), }); const encodedNamespace = encodeURIComponent('foo'); const response = await createServer(makeRequest(`/${encodedNamespace}`)); const html = await getHtml(response); const payload = extractPublishError(html); expect(payload).toMatchObject({ code: 'NO_DEFAULT_PAGE', namespace: 'foo', }); const scriptText = load(html)('#appflowy-publish-error').html()!; expect(scriptText).toContain('\\u003cbar\\u003e'); expect(scriptText).not.toContain(''); }); it('renders publish pages when metadata exists', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', icon: { ty: 0, value: '😀' }, extra: JSON.stringify({ cover: { type: 'custom', value: 'https://img/pic.png' } }), }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('title').text()).toContain('Doc | AppFlowy'); expect(extractPublishError(html)).toBeUndefined(); expect(mockBunFetch).toHaveBeenCalledWith( 'https://api.example.com/api/workspace/v1/published/space/doc', { verbose: false } ); }); it('re-encodes namespace and publishName when fetching metadata for publish pages', async () => { const namespace = 'team slug'; const publishName = 'hello world'; const encodedNamespace = encodeURIComponent(namespace); const encodedPublishName = encodeURIComponent(publishName); mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', }, }, }), }); const response = await createServer(makeRequest(`/${encodedNamespace}/${encodedPublishName}`)); const html = await getHtml(response); expect(response.status).toBe(200); expect(extractPublishError(html)).toBeUndefined(); expect(mockBunFetch).toHaveBeenCalledWith( `https://api.example.com/api/workspace/v1/published/${encodedNamespace}/${encodedPublishName}`, { verbose: false } ); }); it('sets emoji favicon when icon ty=0', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', icon: { ty: 0, value: '😀' }, }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('link[rel="icon"]').attr('href')).toContain('emoji_u1f600.svg'); }); it('sets custom icon favicon when icon ty=2', async () => { const iconValue = JSON.stringify({ iconContent: '', color: '0xFF0000FF', }); mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', icon: { ty: 2, value: iconValue }, }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('link[rel="icon"]').attr('href')).toContain('data:image/svg+xml;base64,'); expect($('link[rel="icon"]').attr('type')).toBe('image/svg+xml'); }); it('handles invalid icon JSON gracefully', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', icon: { ty: 2, value: 'invalid json' }, }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toContain('Doc'); }); it('uses built_in cover image path', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', extra: JSON.stringify({ cover: { type: 'built_in', value: '1' } }), }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('meta[property="og:image"]').attr('content')).toBe('/covers/m_cover_image_1.png'); }); it('uses unsplash cover image', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', extra: JSON.stringify({ cover: { type: 'unsplash', value: 'https://unsplash.com/photo.jpg' } }), }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('meta[property="og:image"]').attr('content')).toBe('https://unsplash.com/photo.jpg'); }); it('handles invalid extra JSON gracefully', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', extra: 'not valid json', }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toContain('Doc'); expect($('meta[property="og:image"]').attr('content')).toBe('/og-image.png'); }); it('captures HTTP errors as FETCH_ERROR', async () => { mockBunFetch.mockResolvedValue({ ok: false, status: 500, }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const payload = extractPublishError(html); expect(payload?.code).toBe('FETCH_ERROR'); expect(payload?.detail).toContain('HTTP error'); }); it('injects publish view lookup errors when metadata request fails validation', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 404, data: { message: 'not found' }, }), }); const response = await createServer(makeRequest('/team/home')); const html = await getHtml(response); const payload = extractPublishError(html); expect(payload).toMatchObject({ code: 'PUBLISH_VIEW_LOOKUP_FAILED', namespace: 'team', publishName: 'home', }); }); it('marks fallback renders when metadata payload is empty', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: null, }), }); const response = await createServer(makeRequest('/org/page')); const html = await getHtml(response); const payload = extractPublishError(html); expect(payload).toMatchObject({ code: 'UNKNOWN_FALLBACK', namespace: 'org', publishName: 'page', }); }); it('captures fetch failures as FETCH_ERROR', async () => { mockBunFetch.mockRejectedValue(new Error('network down')); const response = await createServer(makeRequest('/alpha/doc')); const html = await getHtml(response); const payload = extractPublishError(html); expect(payload?.code).toBe('FETCH_ERROR'); expect(payload?.detail).toContain('network down'); }); it('returns 405 for non-GET methods', async () => { const response = await createServer( makeRequest('/namespace/page', { method: 'POST', body: 'data' }) ); expect(response.status).toBe(405); }); it('start() wires Bun.serve to createServer', () => { const previousBun = (globalThis as unknown as { Bun?: unknown }).Bun; const serve = jest.fn(); (globalThis as unknown as { Bun?: unknown }).Bun = { serve }; start(); expect(serve).toHaveBeenCalledTimes(1); const args = serve.mock.calls[0][0]; expect(args.port).toBe(3000); expect(args.fetch).toBe(createServer); const errorResponse = args.error(new Error('boom')); expect(errorResponse.status).toBe(500); if (previousBun) { (globalThis as unknown as { Bun?: unknown }).Bun = previousBun; } else { delete (globalThis as unknown as { Bun?: unknown }).Bun; } }); it('start() exits process on Bun.serve error', () => { const previousBun = (globalThis as unknown as { Bun?: unknown }).Bun; const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); (globalThis as unknown as { Bun?: unknown }).Bun = { serve: () => { throw new Error('serve failed'); }, }; start(); expect(mockExit).toHaveBeenCalledWith(1); mockExit.mockRestore(); if (previousBun) { (globalThis as unknown as { Bun?: unknown }).Bun = previousBun; } else { delete (globalThis as unknown as { Bun?: unknown }).Bun; } }); it('creates meta tags that do not exist in template', async () => { const minimalTemplate = ` AppFlowy
`; mockReadFileSync.mockReturnValue(minimalTemplate); mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc' }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('meta[property="og:title"]').attr('content')).toBe('Doc | AppFlowy'); expect($('meta[property="og:description"]').length).toBe(1); expect($('meta[name="twitter:card"]').attr('content')).toBe('summary_large_image'); }); it('handles view without name gracefully', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: {}, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toBe('AppFlowy'); }); it('handles ARGB color without alpha correctly', async () => { const iconValue = JSON.stringify({ iconContent: '', color: '#FF0000', }); mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Doc', icon: { ty: 2, value: iconValue }, }, }, }), }); const response = await createServer(makeRequest('/space/doc')); const html = await getHtml(response); const $ = load(html); expect($('link[rel="icon"]').attr('href')).toContain('data:image/svg+xml;base64,'); }); // Additional HTTP method tests it('returns 405 for PUT requests', async () => { const response = await createServer(makeRequest('/space/page', { method: 'PUT' })); expect(response.status).toBe(405); }); it('returns 405 for DELETE requests', async () => { const response = await createServer(makeRequest('/space/page', { method: 'DELETE' })); expect(response.status).toBe(405); }); it('returns 405 for PATCH requests', async () => { const response = await createServer(makeRequest('/space/page', { method: 'PATCH' })); expect(response.status).toBe(405); }); // Content-Type header tests it('returns text/html Content-Type for marketing routes', async () => { const response = await createServer(makeRequest('/login')); expect(response.headers.get('Content-Type')).toBe('text/html'); }); it('returns text/html Content-Type for publish routes', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/space/page')); expect(response.headers.get('Content-Type')).toBe('text/html'); }); // Canonical URL tests it('sets correct canonical URL for publish pages', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/workspace/my-page')); const html = await getHtml(response); const $ = load(html); expect($('link[rel="canonical"]').attr('href')).toBe('https://appflowy.test/workspace/my-page'); }); // OG meta tags tests it('sets correct og:url for publish pages', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/workspace/page')); const html = await getHtml(response); const $ = load(html); expect($('meta[property="og:url"]').attr('content')).toBe('https://appflowy.test/workspace/page'); }); it('sets og:site_name to AppFlowy', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/workspace/page')); const html = await getHtml(response); const $ = load(html); expect($('meta[property="og:site_name"]').attr('content')).toBe('AppFlowy'); }); it('sets og:type to website', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/workspace/page')); const html = await getHtml(response); const $ = load(html); expect($('meta[property="og:type"]').attr('content')).toBe('website'); }); // Twitter card tests it('sets twitter:card to summary_large_image', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/workspace/page')); const html = await getHtml(response); const $ = load(html); expect($('meta[name="twitter:card"]').attr('content')).toBe('summary_large_image'); }); it('sets twitter:site to @appflowy', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); const response = await createServer(makeRequest('/workspace/page')); const html = await getHtml(response); const $ = load(html); expect($('meta[name="twitter:site"]').attr('content')).toBe('@appflowy'); }); // Edge case tests it('handles special characters in namespace', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ data: {} }), }); const response = await createServer(makeRequest('/test%2Fnamespace')); const html = await getHtml(response); expect(response.status).toBe(200); expect(html).toContain('NO_DEFAULT_PAGE'); }); it('handles emoji in publish name', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: '📝 My Notes' } }, }), }); const response = await createServer(makeRequest('/space/%F0%9F%93%9D-notes')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toBe('📝 My Notes | AppFlowy'); }); it('handles very long page names', async () => { const longName = 'A'.repeat(200); mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: longName } }, }), }); const response = await createServer(makeRequest('/space/page')); const html = await getHtml(response); const $ = load(html); expect(response.status).toBe(200); expect($('title').text()).toBe(`${longName} | AppFlowy`); }); // API endpoint verification it('uses v1 API for publish page lookup', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: { view: { name: 'Test' } }, }), }); await createServer(makeRequest('/myspace/mypage')); expect(mockBunFetch).toHaveBeenCalledWith( 'https://api.example.com/api/workspace/v1/published/myspace/mypage', { verbose: false } ); }); it('uses non-v1 API for namespace lookup', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ data: {} }), }); await createServer(makeRequest('/myspace')); expect(mockBunFetch).toHaveBeenCalledWith( 'https://api.example.com/api/workspace/published/myspace', { verbose: false } ); }); // Error message content tests it('includes user-friendly message for NO_DEFAULT_PAGE error', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ data: {} }), }); const response = await createServer(makeRequest('/space')); const html = await getHtml(response); expect(html).toContain("doesn't have a default published page"); }); it('includes user-friendly message for PUBLISH_VIEW_LOOKUP_FAILED error', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 404 }), }); const response = await createServer(makeRequest('/space/page')); const html = await getHtml(response); expect(html).toContain("page you're looking for doesn't exist"); }); it('includes user-friendly message for FETCH_ERROR', async () => { mockBunFetch.mockRejectedValue(new Error('timeout')); const response = await createServer(makeRequest('/space/page')); const html = await getHtml(response); expect(html).toContain('Unable to load this page'); }); it('includes user-friendly message for UNKNOWN_FALLBACK', async () => { mockBunFetch.mockResolvedValue({ ok: true, json: async () => ({ code: 0, data: null }), }); const response = await createServer(makeRequest('/space/page')); const html = await getHtml(response); expect(html).toContain("couldn't load this page"); }); });