[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:
6 files changed
+561
-76
No files matched your search
@@ -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,
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user