#!/usr/bin/env node /** * Tests for the CMS publish flow (MITHOME-60): * * Unit (scripts/cms-publish.js): * - command shape: marker echo, conditional commit, rebase-abort fallback * - result interpretation: marker → no_changes; no marker → ok; error → error * * Integration (real server + throwaway git repos): * 1. publish with no changes → ok, "Nincs új változtatás.", NO deploy * 2. publish with real changes → ok, commit pushed, deploy ran * 3. publish with rebase conflict → error reported, rebase aborted (repo not * left mid-rebase), push never happened, no deploy */ const assert = require('assert/strict'); const { execFileSync, spawn } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult } = require('../scripts/cms-publish'); const ROOT = path.join(__dirname, '..'); // ── Unit ───────────────────────────────────────────────────────────────────── const cmd = buildPublishCommand('content: frissítve a CMS-ből'); assert.ok(cmd.startsWith('git add . && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker'); assert.ok(cmd.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback'); assert.ok(cmd.endsWith('git push origin main'), 'push last'); const noChanges = interpretPublishResult(null, `__NO_CONTENT_CHANGES__\nAlready up to date.\nTo ssh://…\n * [new] nothing`, ''); assert.equal(noChanges.ok, true); assert.equal(noChanges.hadChanges, false); assert.equal(noChanges.result, 'no_changes'); assert.equal(noChanges.output, 'Nincs új változtatás.'); // Real publish on an unmoved remote: pull prints "Already up to date." AND the // push line — the old regex misclassified this as no_changes; the marker must win. const realPublish = interpretPublishResult(null, '[main abc1234] content: frissítve a CMS-ből\n 1 file changed\nAlready up to date.\nTo ssh://git…\n c2cc701..84a4527 main -> main\n', ''); assert.equal(realPublish.ok, true); assert.equal(realPublish.hadChanges, true); assert.equal(realPublish.result, 'ok'); const failed = interpretPublishResult(new Error('exit 1'), 'CONFLICT (content): Merge conflict in x\nerror: could not apply…', 'error: Failed to rebase'); assert.equal(failed.ok, false); assert.equal(failed.result, 'error'); assert.equal(failed.error, 'error: Failed to rebase'); console.log('cms-publish unit tests: OK'); // ── Integration ────────────────────────────────────────────────────────────── function git(cwd, ...args) { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }); } const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-publish-')); const origin = path.join(tmp, 'origin.git'); const work = path.join(tmp, 'work'); const other = path.join(tmp, 'other'); const deployLog = path.join(tmp, 'deploy.log'); const auditFile = path.join(tmp, 'audit.jsonl'); const PORT = 4127; const BASE = `http://127.0.0.1:${PORT}`; execFileSync('git', ['init', '--bare', '-b', 'main', origin]); execFileSync('git', ['clone', origin, work]); for (const repo of [work]) git(repo, 'config', 'user.email', 'test@test.hu'), git(repo, 'config', 'user.name', 'Test'); fs.writeFileSync(path.join(work, 'home.json'), '{"v": 1}\n'); git(work, 'add', '.'); git(work, 'commit', '-m', 'init'); git(work, 'push', '-u', 'origin', 'main'); const child = spawn('node', ['content-editor.js'], { cwd: ROOT, env: { ...process.env, CONTENT_EDITOR_PORT: String(PORT), CONTENT_EDITOR_AUDIT_FILE: auditFile, CONTENT_EDITOR_CONTENT_DIR: work, CONTENT_EDITOR_DEPLOY_CMD: `echo deploy >> ${deployLog}`, CMS_USER: 'pub-test-user', CMS_PASS: 'pub-test-pass', CMS_DEPLOY_ENV: 'staging', }, stdio: 'ignore', }); async function waitForServer(timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fetch(`${BASE}/logout`); // rate-limit-free probe return; } catch { await new Promise(r => setTimeout(r, 200)); } } throw new Error('server did not start'); } async function publish() { const login = await fetch(`${BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: 'pub-test-user', pass: 'pub-test-pass' }), }); const cookie = (login.headers.get('set-cookie') || '').split(';')[0]; const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie } })).text(); const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1]; const res = await fetch(`${BASE}/publish`, { method: 'POST', headers: { Cookie: cookie, 'X-CSRF-Token': csrf } }); return { status: res.status, body: await res.json() }; } function deployCount() { try { return fs.readFileSync(deployLog, 'utf8').trim().split('\n').filter(Boolean).length; } catch { return 0; } } // The deploy command runs detached after the response — poll for its effect. async function waitForDeployCount(expected, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (deployCount() === expected) return; await new Promise(r => setTimeout(r, 100)); } assert.equal(deployCount(), expected); } async function main() { await waitForServer(); // 1. no changes → ok, no deploy const r1 = await publish(); assert.equal(r1.status, 200); assert.equal(r1.body.ok, true); assert.equal(r1.body.output, 'Nincs új változtatás.'); assert.equal(deployCount(), 0, 'no-op publish must not deploy'); // 2. real change → ok, pushed, deploy ran fs.writeFileSync(path.join(work, 'home.json'), '{"v": 2}\n'); const r2 = await publish(); assert.equal(r2.body.ok, true); await waitForDeployCount(1); assert.match(git(work, 'log', '-1', '--format=%s'), /content: frissítve a CMS-ből/); assert.match(git(work, 'status', '--porcelain'), /^$/, 'worktree clean after publish'); // 3. rebase conflict → error, rebase aborted, no push, no deploy execFileSync('git', ['clone', origin, other]); for (const args of [['config', 'user.email', 'o@test.hu'], ['config', 'user.name', 'Other']]) git(other, ...args); fs.writeFileSync(path.join(other, 'home.json'), '{"v": "remote"}\n'); git(other, 'add', '.'); git(other, 'commit', '-m', 'remote edit'); git(other, 'push', 'origin', 'main'); fs.writeFileSync(path.join(work, 'home.json'), '{"v": "local"}\n'); const r3 = await publish(); assert.equal(r3.body.ok, false, 'conflicting publish must report an error'); await new Promise(r => setTimeout(r, 700)); // give a would-be deploy time to (not) appear assert.equal(deployCount(), 1, 'failed publish must not deploy'); // The local commit exists locally (created before the rebase) but must NOT be pushed. assert.equal(git(work, 'log', '-1', '--format=%s').trim(), 'content: frissítve a CMS-ből'); const localCommit = git(work, 'rev-parse', 'HEAD'); assert.ok(!git(work, 'ls-remote', origin, 'refs/heads/main').includes(localCommit), 'local conflicted commit must not be pushed'); assert.ok(!git(work, 'status').includes('rebase in progress'), 'rebase must be aborted'); // audit trail classification const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l)); const publishResults = audit.filter(e => e.event === 'publish_finished').map(e => e.result); assert.deepEqual(publishResults, ['no_changes', 'ok', 'error']); console.log('Content Editor publish integration test: OK'); } main() .catch(err => { console.error('❌', err.message); process.exitCode = 1; }) .finally(() => { child.kill('SIGTERM'); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } });