diff --git a/src/backend/controllers/homepage/HomepageController.test.ts b/src/backend/controllers/homepage/HomepageController.test.ts new file mode 100644 index 000000000..d1bd13965 --- /dev/null +++ b/src/backend/controllers/homepage/HomepageController.test.ts @@ -0,0 +1,262 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request, RequestHandler, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import type { Actor } from '../../core/actor.js'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock +// redis) and re-registers HomepageController's inline lambda routes +// onto a fresh PuterRouter so each handler is reachable. Tests +// exercise the live PuterHomepageService (it renders the shell HTML +// to res.send) and the real AppStore for /app/:name. + +let server: PuterServer; +let router: PuterRouter; + +beforeAll(async () => { + server = await setupTestServer(); + router = new PuterRouter(); + server.controllers.homepage.registerRoutes(router); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `hpc-${Math.random().toString(36).slice(2, 10)}`; + const created = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + const refreshed = (await server.stores.user.getById(created.id))!; + return { + userId: refreshed.id, + actor: { + user: { + id: refreshed.id, + uuid: refreshed.uuid, + username: refreshed.username, + email: refreshed.email ?? null, + email_confirmed: true, + } as Actor['user'], + }, + }; +}; + +interface CapturedResponse { + statusCode: number; + body: unknown; + contentType?: string; +} + +const makeReq = (init: { + params?: Record; + path?: string; + actor?: Actor; + hostname?: string; + protocol?: string; +}): Request => { + return { + body: {}, + query: {}, + headers: {}, + params: init.params ?? {}, + path: init.path ?? '/', + hostname: init.hostname ?? 'test.local', + protocol: init.protocol ?? 'http', + actor: init.actor, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { statusCode: 200, body: undefined }; + const res = { + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + send: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + set: vi.fn((key: string, value: string) => { + if (key.toLowerCase() === 'content-type') { + captured.contentType = value; + } + return res; + }), + setHeader: vi.fn((key: string, value: string) => { + if (key.toLowerCase() === 'content-type') { + captured.contentType = value; + } + return res; + }), + type: vi.fn((value: string) => { + captured.contentType = value; + return res; + }), + }; + return { res: res as unknown as Response, captured }; +}; + +const findHandler = (method: string, path: string): RequestHandler | null => { + const route = router.routes.find( + (r) => r.method === method && r.path === path, + ); + return route?.handler ?? null; +}; + +const callRoute = async ( + method: string, + path: string, + req: Request, + res: Response, +) => { + const handler = findHandler(method, path); + if (!handler) throw new Error(`No ${method.toUpperCase()} ${path} route`); + await handler(req, res, () => { + throw new Error('handler called next() unexpectedly'); + }); +}; + +// ── Shell routes ──────────────────────────────────────────────────── + +describe('HomepageController shell routes', () => { + it('renders the live shell HTML on the root path', async () => { + const { res, captured } = makeRes(); + await callRoute('get', '/', makeReq({ path: '/' }), res); + // PuterHomepageService.send writes the rendered HTML via res.send. + expect(typeof captured.body).toBe('string'); + const html = String(captured.body); + expect(html).toMatch(//i); + // The configured page title flows through the meta block. + expect(html).toContain('Puter'); + }); + + it('still serves the shell when an authenticated actor is present', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await callRoute('get', '/', makeReq({ path: '/', actor }), res); + expect(typeof captured.body).toBe('string'); + expect(String(captured.body)).toMatch(//i); + }); + + it('serves the shell on /settings, /dashboard, /action, /@:username', async () => { + for (const path of [ + '/settings', + '/settings/*splat', + '/dashboard', + '/dashboard/', + '/action/*splat', + '/@:username', + ]) { + const { res, captured } = makeRes(); + await callRoute('get', path, makeReq({ path }), res); + expect(typeof captured.body).toBe('string'); + expect(String(captured.body)).toMatch(//i); + } + }); +}); + +// ── /app/:name ────────────────────────────────────────────────────── + +describe('HomepageController GET /app/:name', () => { + it('returns 404 (still renders the shell) when the app is unknown', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app/:name', + makeReq({ + params: { name: 'no-such-app' }, + path: '/app/no-such-app', + }), + res, + ); + expect(captured.statusCode).toBe(404); + // The shell still renders so the client router can take over. + expect(typeof captured.body).toBe('string'); + expect(String(captured.body)).toMatch(//i); + }); + + it('renders the shell with the app row in scope when the app exists', async () => { + const { userId } = await makeUser(); + const name = `app-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.app.create( + { + name, + title: 'Cool App', + description: 'a real app row', + index_url: `https://example.com/${name}/`, + approved_for_listing: 1, + }, + { ownerUserId: userId }, + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app/:name', + makeReq({ params: { name }, path: `/app/${name}` }), + res, + ); + + expect(captured.statusCode).toBe(200); + // Shell payload is HTML; the app's title appears in the page meta. + const html = String(captured.body); + expect(html).toMatch(//i); + expect(html).toContain('Cool App'); + }); +}); + +// ── /show/* ───────────────────────────────────────────────────────── + +describe('HomepageController GET /show/*splat', () => { + it('emits a launch_app explorer call with the post-/show path', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/show/*splat', + makeReq({ path: '/show/alice/Documents' }), + res, + ); + // The launch payload is JSON-serialized into the rendered HTML. + // We assert the rendered shell included the explorer launch hint + // pointing at the expected (slashed) path. + const html = String(captured.body); + expect(html).toContain('launch_app'); + expect(html).toContain('explorer'); + expect(html).toContain('/alice/Documents'); + }); +});