[pending-judge] fix(integration): catalog detail reads app.docker.* (DC-160); Shield committed; composition-root e2e

- DC-160: /catalog/:appId read app.image/ports/env/volumes which live under
  app.docker.* — every detail response was empty. Now reads docker.* and
  surfaces defaultPort/subdomain/healthCheck/mediaMount/claimToken/difficulty/
  setupInstructions for the deploy form. List/search use the templates own
  category field (old hand-rolled CATEGORY_MAP mis-categorized apps).
- Shield (routes/shield.js + 5-line app.js mount) committed to source after
  weeks as prod-only drift; regression test asserts the mount exists.
- New composition-root e2e suite: boots REAL createApp() on an ephemeral
  port, probes the historically-broken routes through the REAL mount table
  (wizard wiring, shield, catalog detail, deploys bridge) — the class of bug
  all 3 P7 dogfood findings belonged to. 5 tests.
- Full suite 3499/3499 (155 suites); table-driven DC-160 test covers all 73
  image templates.
This commit is contained in:
Hermes Agent committed 2026-10-07 00:58:03 -07:00
1 parent 1fa5ba8baa
commit 8744609887
6 files changed
+561 -76

No files matched your search

+70 -76
View File
@@ -1,79 +1,75 @@
/**
* DC-104: App Catalog API — curated templates with categories and search
* DC-160: App Catalog API — curated templates with categories and search
*
* Exposes the existing app-templates.js as a browsable catalog.
* GET /api/v1/catalog — list all apps (with optional category filter)
* GET /api/v1/catalog/:appId — get details for a specific app
* GET /api/v1/catalog/search — search apps by name/category/keyword
*
* DC-160 FIX: the detail endpoint used to read `app.image` / `app.ports` /
* `app.env` / `app.volumes` — but every template stores these under
* `app.docker.*`. The result was a 200 with empty image/ports/env/volumes
* for EVERY app. Now reads `app.docker.*` and additionally surfaces the
* install-time config fields the App Selector needs to render its deploy
* form (defaultPort, subdomain, healthCheck, mediaMount, claimToken,
* difficulty, setupInstructions).
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
// Category mapping for common apps
const CATEGORY_MAP = {
plex: 'media', jellyfin: 'media', emby: 'media',
sonarr: 'media', radarr: 'media', prowlarr: 'media', lidarr: 'media',
readarr: 'media', qbittorrent: 'media', transmission: 'media',
sabnzbd: 'media', nzbget: 'media',
nextcloud: 'productivity', vaultwarden: 'productivity',
gitea: 'development', portainer: 'development', code: 'development',
node: 'development',
redis: 'database', postgres: 'database', mariadb: 'database', mongo: 'database',
mysql: 'database',
nginx: 'network', caddy: 'network', adguard: 'network', pihole: 'network',
technitium: 'network', wireguard: 'network',
homeassistant: 'smart-home', mosquitto: 'smart-home',
grafana: 'monitoring', prometheus: 'monitoring', uptimekuma: 'monitoring',
};
function getTemplateCategory(template) {
const id = (template.id || template.name || '').toLowerCase();
for (const [key, cat] of Object.entries(CATEGORY_MAP)) {
if (id.includes(key)) return cat;
}
return 'other';
}
module.exports = function({ APP_TEMPLATES, asyncHandler } = {}) {
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
const router = express.Router();
// Normalize APP_TEMPLATES (array or { id: {...} } map) into a value array.
const appArray = Array.isArray(APP_TEMPLATES)
? [...APP_TEMPLATES]
: Object.values(APP_TEMPLATES || {});
const appId = (t) => t.id || (t.name || '').toLowerCase().replace(/\s+/g, '-');
const POPULAR = new Set([
'plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent',
]);
// Catalog entry shape used by list + search results. Uses the template's
// OWN category field (DC-160: the old hand-rolled CATEGORY_MAP guessed by
// substring match — plex→media — which diverged from the templates'
// real categories and mis-categorized anything unlisted).
function listEntry(t) {
return {
id: appId(t),
name: t.name,
description: t.description || '',
category: t.category || 'Other',
logo: t.logo || null,
difficulty: t.difficulty || null,
popular: POPULAR.has(appId(t)),
};
}
// GET /api/v1/catalog — list all apps
router.get('/catalog', wrap(async (req, res) => {
const { category, sort } = req.query;
let apps = APP_TEMPLATES || [];
// APP_TEMPLATES can be an array or an object map { plex: {...}, ... }
let appArray = Array.isArray(apps) ? apps : Object.values(apps);
let entries = appArray.map(listEntry);
// Build catalog entries
let entries = appArray.map(t => ({
id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'),
name: t.name,
description: t.description || '',
category: getTemplateCategory(t),
logo: t.logo || null,
popular: ['plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent']
.includes((t.id || t.name || '').toLowerCase().replace(/\s+/g, '-')),
}));
// Filter by category
// Filter by category (templates' own category, case-insensitive)
if (category && category !== 'all') {
entries = entries.filter(e => e.category === category);
const want = String(category).toLowerCase();
entries = entries.filter((e) => String(e.category).toLowerCase() === want);
}
// Sort
// Sort: default popular-first then name; sort=name alphabetical only.
if (sort === 'name') {
entries.sort((a, b) => a.name.localeCompare(b.name));
} else {
// Default: popular first, then alphabetical
entries.sort((a, b) => {
if (a.popular !== b.popular) return a.popular ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
// Get categories
const categories = [...new Set(entries.map(e => e.category))].sort();
const categories = [...new Set(appArray.map((t) => t.category || 'Other'))].sort();
ok(res, {
total: entries.length,
@@ -89,48 +85,46 @@ module.exports = function({ APP_TEMPLATES, asyncHandler } = {}) {
return errorResponse(res, 400, 'Search query (q) is required');
}
const allApps = APP_TEMPLATES || [];
const appArray = Array.isArray(allApps) ? allApps : Object.values(allApps);
const apps = appArray.filter(t => {
const name = (t.name || '').toLowerCase();
const desc = (t.description || '').toLowerCase();
const cat = getTemplateCategory(t).toLowerCase();
return name.includes(q) || desc.includes(q) || cat.includes(q);
}).map(t => ({
id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'),
name: t.name,
description: t.description || '',
category: getTemplateCategory(t),
}));
const apps = appArray
.filter((t) => {
const name = (t.name || '').toLowerCase();
const desc = (t.description || '').toLowerCase();
const cat = String(t.category || '').toLowerCase();
const id = appId(t).toLowerCase();
return name.includes(q) || desc.includes(q) || cat.includes(q) || id.includes(q);
})
.map(listEntry);
ok(res, { query: q, results: apps.length, apps });
}));
// GET /api/v1/catalog/:appId — get specific app details
// GET /api/v1/catalog/:appId — get specific app details (DC-160: reads app.docker.*)
router.get('/catalog/:appId', wrap(async (req, res) => {
const appId = req.params.appId;
const allApps = APP_TEMPLATES || [];
const appArray = Array.isArray(allApps) ? allApps : Object.values(allApps);
const app = appArray.find(t => {
const tid = (t.id || t.name?.toLowerCase().replace(/\s+/g, '-'));
return tid === appId;
});
const app = appArray.find((t) => appId(t) === req.params.appId);
if (!app) {
return errorResponse(res, 404, `App '${appId}' not found in catalog`);
return errorResponse(res, 404, `App '${req.params.appId}' not found in catalog`);
}
const d = app.docker || {};
ok(res, {
id: app.id || appId,
id: app.id || req.params.appId,
name: app.name,
description: app.description || '',
category: getTemplateCategory(app),
image: app.image || '',
ports: app.ports || [],
env: app.env || {},
volumes: app.volumes || [],
network: app.network || 'bridge',
restart: app.restart || 'unless-stopped',
category: app.category || 'Other',
image: d.image || '',
ports: d.ports || [],
env: d.environment || {},
volumes: d.volumes || [],
network: d.network || 'bridge',
restart: d.restart || 'unless-stopped',
defaultPort: app.defaultPort || null,
subdomain: app.subdomain || appId(app),
healthCheck: app.healthCheck || null,
mediaMount: app.mediaMount || null,
claimToken: app.claimToken || null,
difficulty: app.difficulty || null,
setupInstructions: app.setupInstructions || null,
});
}));