diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 9f1eb41..5aac826 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -108,7 +108,7 @@ jobs:
TIGER_TEST_DB_PASS: root
# The floor we enforce today (report-only warning above it). Bump this as coverage climbs so it
# can only go UP — the ratchet that gets us to 90% without ever regressing.
- MIN_COVERAGE: '55'
+ MIN_COVERAGE: '66'
steps:
- uses: actions/checkout@v4
diff --git a/modules/backup/controllers/IndexController.php b/modules/backup/controllers/IndexController.php
index 8b95a8b..24a308e 100644
--- a/modules/backup/controllers/IndexController.php
+++ b/modules/backup/controllers/IndexController.php
@@ -78,19 +78,19 @@ public function uploadAction()
$response = $this->getResponse();
$response->setHeader('Content-Type', 'application/json', true);
- if (($this->getParam('confirm', '')) !== 'RESTORE') { $response->setBody($this->_json(0, 'backup.restore.confirm')); return; }
+ if (($this->getParam('confirm', '')) !== 'RESTORE') { $response->setBody($this->_jsonBody(0, 'backup.restore.confirm')); return; }
$file = $_FILES['archive'] ?? null;
- if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $response->setBody($this->_json(0, 'backup.upload.failed')); return; }
+ if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $response->setBody($this->_jsonBody(0, 'backup.upload.failed')); return; }
$dir = APPLICATION_ROOT . '/var/backup';
@is_dir($dir) || @mkdir($dir, 0775, true);
$tmp = $dir . '/upload-' . bin2hex(random_bytes(4)) . '.zip';
- if (!@move_uploaded_file($file['tmp_name'], $tmp)) { $response->setBody($this->_json(0, 'backup.upload.failed')); return; }
+ if (!@move_uploaded_file($file['tmp_name'], $tmp)) { $response->setBody($this->_jsonBody(0, 'backup.upload.failed')); return; }
// Validate it's a TigerBackup archive before doing anything destructive.
if (Tiger_Backup_Archive::read($tmp, 'manifest.json') === false) {
@unlink($tmp);
- $response->setBody($this->_json(0, 'backup.upload.invalid'));
+ $response->setBody($this->_jsonBody(0, 'backup.upload.invalid'));
return;
}
@@ -103,9 +103,9 @@ public function uploadAction()
@unlink($tmp);
if (($res['status'] ?? '') === 'ok') {
- $response->setBody($this->_json(1, 'backup.restore.done', ['restored' => $res['restored']]));
+ $response->setBody($this->_jsonBody(1, 'backup.restore.done', ['restored' => $res['restored']]));
} else {
- $response->setBody($this->_json(0, APPLICATION_ENV !== 'production' ? ('Restore failed: ' . ($res['error'] ?? '')) : 'backup.restore.failed'));
+ $response->setBody($this->_jsonBody(0, APPLICATION_ENV !== 'production' ? ('Restore failed: ' . ($res['error'] ?? '')) : 'backup.restore.failed'));
}
}
@@ -133,7 +133,7 @@ protected function _settings(): array
}
/** Minimal JSON envelope for the two non-service actions (message key is translated client-side-agnostic). */
- protected function _json(int $result, string $messageKey, array $data = []): string
+ protected function _jsonBody(int $result, string $messageKey, array $data = []): string
{
$t = Zend_Registry::isRegistered('Zend_Translate') ? Zend_Registry::get('Zend_Translate') : null;
$msg = $t ? $t->translate($messageKey) : $messageKey;
diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md
index 9e24a5c..0ba76ad 100644
--- a/tests/COVERAGE-PLAN.md
+++ b/tests/COVERAGE-PLAN.md
@@ -609,6 +609,24 @@ provider/authority/GA/reCAPTCHA live HTTP, ClamAV/Rekognition scanners, AWS-SDK
mints `MODULES_PATH` etc. → the app-boot test runs `#[RunInSeparateProcess]`. **RECOMMENDED base hygiene (deferred):** reset
`Org::$_siteOrgId` in `IntegrationTestCase::tearDown`. CI floor `MIN_COVERAGE` 35 → 55.
+### Wave 6 — remaining modules + the dispatch harness (LANDED 2026-07-24) → coverage 59.2% → 70.0%
+First landed a **controller dispatch harness** (`tests/Support/ControllerTestCase.php`, PR #66): instantiates a
+controller + dispatches ONE action with view-rendering OFF, so the action BODY runs (branch logic, `_json` body,
+redirect/`_forward`) without the theme/view-script stack — unlocking `core/controllers` (0% before). Then 5 agents:
+core-controllers 0→**87%** (all 6: Api 80, Auth 79, Error 95, Index 87, Page 98, Admin 91), cms 33→**94**, code 41→**90**,
+agent module 6→**66-78**, system 37→**59**, + a module-controller sweep (profile 68, access 84, media/analytics/identity/
+blog/signup/search/schedule controllers 90-100%). ~167 tests; combined suite **1617 green**.
+- **REAL BUG FIXED (a test found it):** `Backup_IndexController::_json(int,string,array):string` was an INCOMPATIBLE
+ override of `Tiger_Controller_Action::_json($data,$status)` → a PHP 8.5 **fatal at class load**, so EVERY `/backup`
+ request 500'd (the backup admin UI was entirely broken). Renamed the helper to `_jsonBody()`; replaced the
+ characterization test with real dispatch coverage.
+- **Harness hardening (3 agents hit it):** folded `redirector->setExit(false)` into `ControllerTestCase` setUp (the
+ redirector `exit`s after headers by default → would kill the PHPUnit process on a controller redirect).
+- CI floor 55 → 66. Ceilings (bounded honestly, not chased): `Updates::_applyOne` would run a real `composer update`/
+ `vendor/` swap on a dev box (covered at guard level only); `is_uploaded_file()` uploads; live-model agent turns;
+ render-only `.phtml` leaves; `pcov` under-reports module `elements()` forms (multi-line-array-literal artifact).
+
+### Next waves (priority order) — the drive to 90%
### Next waves (priority order) — the drive to 90%
- **Wave 6 — the remaining MODULES:** `agent` module (462 @ 5% — needs a provider-adapter stub, but the `Tiger_Agent_*`
library it builds on is now ~72% so the seams exist), `cms` module (510 @ 33%), `system` remainder (579 @ 37%), `code`
diff --git a/tests/Integration/Access/ControllersTest.php b/tests/Integration/Access/ControllersTest.php
new file mode 100644
index 0000000..15a3963
--- /dev/null
+++ b/tests/Integration/Access/ControllersTest.php
@@ -0,0 +1,104 @@
+loginAs('admin');
+ $this->dispatchAction(Access_UserController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Users', (string) $view->title);
+ $this->assertTrue((bool) $view->useDataTables);
+ }
+
+ #[Test]
+ public function user_edit_renders_a_blank_form_for_a_new_user(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Access_UserController::class, 'edit', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('New User', (string) $view->title);
+ $this->assertInstanceOf(\Access_Form_User::class, $view->form);
+ $this->assertNull($view->user);
+ }
+
+ #[Test]
+ public function user_edit_prefills_the_form_for_an_existing_user(): void
+ {
+ $id = (new Tiger_Model_User())->insert([
+ 'email' => 'edituser@example.test',
+ 'username' => 'edituser',
+ 'status' => 'active',
+ ]);
+ $this->loginAs('admin');
+ $this->dispatchAction(Access_UserController::class, 'edit', ['id' => $id], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Edit User', (string) $view->title);
+ $this->assertNotNull($view->user);
+ $this->assertSame('edituser@example.test', $view->form->getValues()['email']);
+ }
+
+ #[Test]
+ public function org_index_renders_the_datatables_shell(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Access_OrgController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Organizations', (string) $view->title);
+ $this->assertTrue((bool) $view->useDataTables);
+ }
+
+ #[Test]
+ public function org_edit_renders_a_blank_form_for_a_new_org(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Access_OrgController::class, 'edit', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('New Organization', (string) $view->title);
+ $this->assertInstanceOf(\Access_Form_Org::class, $view->form);
+ $this->assertNull($view->org);
+ }
+
+ #[Test]
+ public function org_edit_prefills_the_form_for_an_existing_org(): void
+ {
+ $id = (new Tiger_Model_Org())->insert([
+ 'name' => 'Edit Org',
+ 'slug' => 'edit-org-' . substr(md5((string) mt_rand()), 0, 8),
+ 'status' => 'active',
+ ]);
+ $this->loginAs('admin');
+ $this->dispatchAction(Access_OrgController::class, 'edit', ['id' => $id], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Edit Organization', (string) $view->title);
+ $this->assertNotNull($view->org);
+ $this->assertSame('Edit Org', $view->form->getValues()['name']);
+ }
+}
diff --git a/tests/Integration/Agent/AdminControllerTest.php b/tests/Integration/Agent/AdminControllerTest.php
new file mode 100644
index 0000000..d179d89
--- /dev/null
+++ b/tests/Integration/Agent/AdminControllerTest.php
@@ -0,0 +1,93 @@
+priorUnitTestMode = Zend_Session::$_unitTestEnabled;
+ Zend_Session::$_unitTestEnabled = true;
+ $_SESSION = [];
+
+ // The action titles the page via the registered translator; provide a minimal one.
+ Zend_Registry::set('Zend_Translate', new Zend_Translate([
+ 'adapter' => 'array',
+ 'content' => ['agent.settings.title' => 'AI Agent'],
+ 'locale' => 'en',
+ ]));
+ Zend_Registry::set('Zend_Config', new Zend_Config([], true));
+ }
+
+ protected function tearDown(): void
+ {
+ $_SESSION = [];
+ Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode;
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function index_prefills_the_form_and_exposes_the_settings_view_model(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Agent_AdminController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+
+ $this->assertStringContainsString('AI Agent', (string) $view->title, 'the title runs through the translator');
+ $this->assertInstanceOf(\Agent_Form_Settings::class, $view->form, 'the settings form is handed to the view');
+
+ // The provider roster the dropdown renders is the live factory options.
+ $this->assertSame(Tiger_Agent_Provider_Factory::options(), $view->providers);
+ $this->assertArrayHasKey('anthropic', (array) $view->providers);
+
+ // Prefill + capability flags the view branches on.
+ $this->assertSame('anthropic', $view->provider, 'defaults to anthropic with no stored provider');
+ $this->assertNotSame('', (string) $view->model, 'a default model is offered');
+ $this->assertIsBool($view->enabled);
+ $this->assertIsBool($view->connected);
+ $this->assertIsBool($view->cryptoReady);
+ $this->assertContains($view->modeMax, ['ask', 'auto', 'yolo']);
+ }
+
+ #[Test]
+ public function index_reflects_stored_provider_and_model_config(): void
+ {
+ Zend_Registry::set('Zend_Config', new Zend_Config([
+ 'tiger' => ['agent' => ['provider' => 'openai', 'model' => 'gpt-4o']],
+ ], true));
+ $this->loginAs('admin');
+ $this->dispatchAction(Agent_AdminController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertSame('openai', $view->provider, 'the stored provider is surfaced');
+ $this->assertSame('gpt-4o', $view->model, 'the stored model is surfaced');
+ }
+}
diff --git a/tests/Integration/Agent/AgentServiceTest.php b/tests/Integration/Agent/AgentServiceTest.php
new file mode 100644
index 0000000..b520d2a
--- /dev/null
+++ b/tests/Integration/Agent/AgentServiceTest.php
@@ -0,0 +1,419 @@
+_context($p); }
+ public function take(array $p): array { return $this->_takeAttachments($p); }
+ public function mime(string $tmp, string $ext): string { return $this->_mime($tmp, $ext); }
+ public function dom($raw): string { return $this->_domFeedback($raw); }
+ public function resolve(string $id) { return $this->_resolveConversation($id); }
+ public function role(): string { return $this->_role(); }
+}
+
+/**
+ * Agent_Service_Agent — the aside's turn-engine `/api` service (Wave 6).
+ *
+ * Covered hermetically: the `_ready` gate (deny-by-default for a role below `manager`; the
+ * unconfigured error when the feature is off / no key), form validation, `approve` end to end (run
+ * lookup + owner scoping, ledger decode + index parsing, the Forge-gated execution loop, status
+ * computation, the assistant-message meta re-sync), `conversations` + `history` (owner-scoped reads,
+ * the rotated-CSRF success envelope), `uploadFile`'s guard, and the turn helpers via a subclass
+ * (`_context` sanitize, `_takeAttachments` owner scoping, `_mime`, `_domFeedback`, `_resolveConversation`).
+ *
+ * BOUNDARY (see WAVE6-FINDINGS-agentmod.md): a SUCCESSFUL turn (`send` / `resume`) runs
+ * Tiger_Agent_Loop → a live provider HTTP call, and the Loop builds its adapter through
+ * Tiger_Agent_Provider_Factory::make() with no injection seam — so the turn itself is not driven here
+ * (mirroring ForgeAclTest, which likewise treats the live dispatch as the boundary). `approve`'s
+ * best-effort follow-up turn is kept off the network by pointing the run at a dangling conversation.
+ */
+#[CoversClass(Agent_Service_Agent::class)]
+final class AgentServiceTest extends IntegrationTestCase
+{
+ use EnsuresAttachmentTable;
+
+ private const CRYPTO_KEY = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=';
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->ensureAttachmentTable();
+ Zend_Registry::set('tiger.auth.stateless', true); // CSRF-immune API path (no session in CLI)
+ }
+
+ protected function tearDown(): void
+ {
+ $reg = Zend_Registry::getInstance();
+ if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); }
+ parent::tearDown();
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ self::dropAttachmentTable(); // never leave the side-loaded table for InstallerLifecycleTest
+ parent::tearDownAfterClass();
+ }
+
+ /** Switch the agent ON + connected (feature flag + an encrypted BYO key crypto can read). */
+ private function enableAgent(): void
+ {
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['crypto' => ['key' => self::CRYPTO_KEY]]], true));
+ $enc = Tiger_Crypto::encrypt('sk-test-key');
+ Zend_Registry::set('Zend_Config', new Zend_Config([
+ 'tiger' => [
+ 'crypto' => ['key' => self::CRYPTO_KEY],
+ 'agent' => ['enabled' => '1', 'api_key_enc' => $enc, 'mode_max' => 'auto'],
+ ],
+ ], true));
+ }
+
+ /** Dispatch the real service and hand back the response object. */
+ private function call(string $action, array $params = []): object
+ {
+ return (new Agent_Service_Agent(['action' => $action] + $params))->getResponse();
+ }
+
+ // ===== the _ready gate ========================================================================
+
+ #[Test]
+ public function a_role_below_manager_is_denied_every_action(): void
+ {
+ $this->enableAgent();
+ foreach (['guest', 'user'] as $role) {
+ $this->loginAs($role);
+ foreach (['send', 'approve', 'resume', 'conversations', 'history', 'uploadFile'] as $action) {
+ $res = $this->call($action);
+ $this->assertSame(0, (int) $res->result, "{$role} denied on {$action}");
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages), "{$role}/{$action} denial");
+ }
+ }
+ }
+
+ #[Test]
+ public function an_allowed_role_is_told_the_agent_is_unconfigured_when_off(): void
+ {
+ Zend_Registry::set('Zend_Config', new Zend_Config([], true)); // feature explicitly OFF (no leaked enable)
+ $this->loginAs('manager'); // may chat, but the feature is off / no key
+ $res = $this->call('conversations');
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('unconfigured', json_encode($res->messages));
+ }
+
+ // ===== send / resume — the validation surface (the turn itself is the live boundary) ==========
+
+ #[Test]
+ public function send_rejects_an_empty_message_with_form_errors(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('manager');
+ $res = $this->call('send', ['message' => ' ']); // trims to empty → the required validator fires
+ $this->assertSame(0, (int) $res->result);
+ $this->assertNotNull($res->form);
+ $this->assertArrayHasKey('message', (array) $res->form);
+ }
+
+ #[Test]
+ public function resume_requires_a_conversation_id_then_scopes_to_the_owner(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('manager');
+
+ $missing = $this->call('resume'); // no conversation_id → form error
+ $this->assertSame(0, (int) $missing->result);
+ $this->assertArrayHasKey('conversation_id', (array) $missing->form);
+
+ $notMine = $this->call('resume', ['conversation_id' => 'not-a-real-conversation', 'results' => '[]']);
+ $this->assertSame(0, (int) $notMine->result, 'a conversation the caller does not own is refused');
+ $this->assertStringContainsString('run_missing', json_encode($notMine->messages));
+ }
+
+ // ===== conversations + history (owner-scoped reads + rotated-CSRF envelope) ====================
+
+ #[Test]
+ public function conversations_lists_the_callers_threads_newest_first_with_a_fresh_csrf(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('manager'); // user-manager / org-test
+ $conv = new Tiger_Model_AgentConversation();
+ $conv->start('org-test', 'user-manager', 'First thread', 'anthropic', 'claude-sonnet-5');
+ $conv->start('org-test', 'user-manager', '', 'anthropic', 'claude-sonnet-5'); // titleless
+ $conv->start('org-test', 'user-other', 'Someone else', 'anthropic', 'claude-sonnet-5'); // another owner
+
+ $res = $this->call('conversations');
+ $this->assertSame(1, (int) $res->result);
+ $titles = array_column($res->data['conversations'], 'title');
+ $this->assertContains('First thread', $titles);
+ $this->assertContains('New chat', $titles, 'a titleless thread renders as "New chat"');
+ $this->assertNotContains('Someone else', $titles, "another user's thread never appears");
+ $this->assertArrayHasKey('_csrf', $res->data, 'every success rotates a fresh Agent CSRF token');
+ }
+
+ #[Test]
+ public function history_returns_a_transcript_for_an_owned_thread_and_refuses_others(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('manager');
+
+ $conv = new Tiger_Model_AgentConversation();
+ $id = $conv->start('org-test', 'user-manager', 'Chat', 'anthropic', 'claude-sonnet-5');
+ $msg = new Tiger_Model_AgentMessage();
+ $msg->append($id, Tiger_Model_AgentMessage::ROLE_USER, 'hello');
+ $msg->append($id, Tiger_Model_AgentMessage::ROLE_ASSISTANT, 'hi there', ['done' => true]);
+
+ $res = $this->call('history', ['conversation_id' => $id]);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame($id, $res->data['conversation_id']);
+ $this->assertCount(2, $res->data['messages']);
+ $this->assertSame('user', $res->data['messages'][0]['role']);
+ $this->assertSame('hi there', $res->data['messages'][1]['content']);
+ $this->assertIsArray($res->data['messages'][1]['meta'], 'the assistant meta decodes back to an array');
+
+ $notMine = $this->call('history', ['conversation_id' => 'nope']);
+ $this->assertSame(0, (int) $notMine->result);
+ $this->assertStringContainsString('run_missing', json_encode($notMine->messages));
+ }
+
+ // ===== uploadFile — the guard (the store seam is the CLI boundary; see findings) ==============
+
+ #[Test]
+ public function uploadFile_fails_cleanly_when_no_file_was_posted(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('manager');
+ // is_uploaded_file() is false for anything not arriving via a real HTTP multipart POST, so the
+ // CLI path always takes the guard — the store seam itself is covered by AttachmentModelTest.
+ $res = $this->call('uploadFile');
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('agent.file.failed', json_encode($res->messages));
+ }
+
+ // ===== approve — end to end, kept off the network =============================================
+
+ /** Insert a run owned by $userId whose conversation_id is DANGLING (so the best-effort follow-up
+ * turn short-circuits to null before any provider call), carrying the given action ledger. */
+ private function seedRun(string $userId, array $ledger): string
+ {
+ return (new Tiger_Model_AgentRun())->insert([
+ 'conversation_id' => 'dangling-conversation-' . substr(md5($userId . microtime()), 0, 8),
+ 'user_id' => $userId,
+ 'status' => Tiger_Model_AgentRun::STATUS_BLOCKED,
+ 'steps' => 1,
+ 'actions' => json_encode($ledger),
+ ]);
+ }
+
+ #[Test]
+ public function approve_requires_a_run_id_and_a_real_owned_run(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('admin');
+
+ $noId = $this->call('approve'); // form: run_id required
+ $this->assertSame(0, (int) $noId->result);
+ $this->assertArrayHasKey('run_id', (array) $noId->form);
+
+ $missing = $this->call('approve', ['run_id' => 'does-not-exist']);
+ $this->assertSame(0, (int) $missing->result);
+ $this->assertStringContainsString('run_missing', json_encode($missing->messages));
+ }
+
+ #[Test]
+ public function approve_treats_a_run_with_a_non_array_ledger_as_missing(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('admin');
+ $runId = (new Tiger_Model_AgentRun())->insert([
+ 'conversation_id' => 'dangling', 'user_id' => 'user-admin',
+ 'status' => Tiger_Model_AgentRun::STATUS_BLOCKED, 'steps' => 1, 'actions' => null,
+ ]);
+ $res = $this->call('approve', ['run_id' => $runId, 'all' => '1']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('run_missing', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function approve_with_nothing_targeted_leaves_the_proposal_blocked(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('admin');
+ $ledger = [[
+ 'type' => 'file', 'status' => 'proposed', 'summary' => 'Write demo/x.phtml',
+ 'action' => ['type' => 'file', 'path' => 'demo/x.phtml', 'contents' => '
x
', 'reason' => 'r'],
+ ]];
+ $runId = $this->seedRun('user-admin', $ledger);
+
+ // A JSON-array index that matches nothing → the proposal is never executed and stays blocked.
+ $res = $this->call('approve', ['run_id' => $runId, 'indexes' => '[9]']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame(Tiger_Model_AgentRun::STATUS_BLOCKED, $res->data['status']);
+ $this->assertSame('proposed', $res->data['actions'][0]['status'], 'the untargeted action is untouched');
+ $this->assertNull($res->data['follow'], 'no follow-up turn when nothing executed');
+ }
+
+ #[Test]
+ public function approve_runs_the_targeted_action_through_the_forge_and_resyncs_the_message(): void
+ {
+ $this->enableAgent();
+ $this->loginAs('admin'); // admin lacks the superadmin `file` tier → the Forge returns 'denied'
+
+ $ledger = [[
+ 'type' => 'file', 'status' => 'proposed', 'summary' => 'Write demo/x.phtml',
+ 'action' => ['type' => 'file', 'path' => 'demo/x.phtml', 'contents' => 'x
', 'reason' => 'r'],
+ ]];
+ $runId = $this->seedRun('user-admin', $ledger);
+
+ // Mirror the assistant turn's chip meta, so the re-sync (meta.status) has a row to update.
+ $convId = (string) (new Tiger_Model_AgentRun())->ownedById($runId, 'user-admin')->conversation_id;
+ $msg = new Tiger_Model_AgentMessage();
+ $msgId = $msg->append($convId, Tiger_Model_AgentMessage::ROLE_ASSISTANT, 'proposing', ['actions' => $ledger, 'status' => 'blocked'], $runId);
+
+ $res = $this->call('approve', ['run_id' => $runId, 'all' => '1']);
+
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('denied', $res->data['actions'][0]['status'], 'the Forge ran the action and gated it by ACL');
+ $this->assertSame(Tiger_Model_AgentRun::STATUS_OK, $res->data['status'], 'a denied (not errored/blocked) action → ok');
+
+ // The run ledger + the mirrored message meta were both rewritten to the executed outcome.
+ $run = (new Tiger_Model_AgentRun())->ownedById($runId, 'user-admin');
+ $this->assertStringContainsString('denied', (string) $run->actions);
+
+ $metaRow = $this->db->fetchOne('SELECT meta FROM agent_message WHERE message_id = ?', [$msgId]);
+ $meta = json_decode((string) $metaRow, true);
+ $this->assertSame(Tiger_Model_AgentRun::STATUS_OK, $meta['status'], 'the assistant chip meta re-synced to ok');
+ }
+
+ // ===== the turn helpers (exposed subclass, no live model) =====================================
+
+ private function exposed(): ExposedAgentService
+ {
+ $this->enableAgent();
+ return new ExposedAgentService([]); // empty message → the base constructor dispatches nothing
+ }
+
+ #[Test]
+ public function context_sanitizes_the_path_and_the_declared_editable_targets(): void
+ {
+ $this->loginAs('manager');
+ $svc = $this->exposed();
+
+ $ctx = $svc->ctx(['context' => json_encode(['path' => '/admin/foo', 'targets' => [
+ ['name' => 'ti tle!!', 'label' => 'The Title', 'kind' => 'html'], // name sanitized, kind kept
+ ['label' => 'no name'], // dropped (no name)
+ ['name' => 'body', 'kind' => 'weird'], // unknown kind → text
+ ]])]);
+
+ $this->assertSame('/admin/foo', $ctx['path']);
+ $this->assertCount(2, $ctx['targets']);
+ $this->assertSame('title', $ctx['targets'][0]['name'], 'spaces + punctuation stripped from the target name');
+ $this->assertSame('html', $ctx['targets'][0]['kind']);
+ $this->assertSame('text', $ctx['targets'][1]['kind'], 'an unknown kind falls back to text');
+ }
+
+ #[Test]
+ public function context_rejects_a_non_path_and_accepts_an_array_envelope(): void
+ {
+ $this->loginAs('manager');
+ $svc = $this->exposed();
+ // A path with a query string doesn't match the strict pattern → dropped to ''.
+ $this->assertSame('', $svc->ctx(['context' => ['path' => '/x?y=1']])['path']);
+ $this->assertSame([], $svc->ctx([])['targets'], 'no context → empty targets');
+ }
+
+ #[Test]
+ public function takeAttachments_resolves_only_the_callers_pending_ids(): void
+ {
+ $this->loginAs('manager');
+ $svc = $this->exposed();
+
+ $am = new Agent_Model_Attachment();
+ $mine = $am->insert(['conversation_id' => null, 'message_id' => null, 'user_id' => 'user-manager',
+ 'org_id' => 'org-test', 'disk' => 'local', 'filename' => 'a.txt', 'mime_type' => 'text/plain',
+ 'file_size' => 3, 'kind' => 'file', 'storage_key' => 'agent/x/a.txt', 'extract' => 'body text']);
+ $theirs = $am->insert(['conversation_id' => null, 'message_id' => null, 'user_id' => 'user-other',
+ 'org_id' => 'org-test', 'disk' => 'local', 'filename' => 'b.txt', 'mime_type' => 'text/plain',
+ 'file_size' => 3, 'kind' => 'file']);
+
+ $out = $svc->take(['attachment_ids' => "{$mine}, {$theirs}"]);
+ $this->assertSame([$mine], $out['ids'], 'only my pending row resolves');
+ $this->assertCount(1, $out['loop']);
+ $this->assertSame('a.txt', $out['loop'][0]['filename']);
+ $this->assertSame('body text', $out['loop'][0]['extract']);
+
+ $this->assertSame(['ids' => [], 'loop' => []], $svc->take(['attachment_ids' => ' ']), 'blank → nothing');
+ }
+
+ #[Test]
+ public function mime_detects_from_content_then_falls_back_to_the_extension_map(): void
+ {
+ $this->loginAs('manager');
+ $svc = $this->exposed();
+
+ $tmp = tempnam(sys_get_temp_dir(), 'agmime');
+ file_put_contents($tmp, "plain text content\n");
+ $this->assertSame('text/plain', $svc->mime($tmp, 'txt'), 'finfo detects text/plain');
+ @unlink($tmp);
+
+ // A path finfo can't read + a known extension → the extension map answers.
+ $this->assertSame('application/json', $svc->mime('/no/such/file', 'json'));
+ $this->assertSame('application/octet-stream', $svc->mime('/no/such/file', 'unknownext'));
+ }
+
+ #[Test]
+ public function domFeedback_formats_reads_writes_and_failures(): void
+ {
+ $this->loginAs('manager');
+ $svc = $this->exposed();
+
+ $out = $svc->dom(json_encode([
+ ['target' => 'headline', 'ok' => 1, 'kind' => 'text', 'content' => 'current copy'],
+ ['target' => 'missing', 'ok' => 0, 'error' => 'not on the page'],
+ ['target' => 'body', 'ok' => 1], // no content key → an editor write
+ ]));
+
+ $this->assertStringContainsString('current content', $out);
+ $this->assertStringContainsString('current copy', $out);
+ $this->assertStringContainsString('FAILED — not on the page', $out);
+ $this->assertStringContainsString('updated in the editor', $out);
+
+ $this->assertStringContainsString('(none)', $svc->dom('not-json'), 'unparseable results degrade gracefully');
+ }
+
+ #[Test]
+ public function resolveConversation_loads_an_owned_thread_else_starts_a_fresh_one(): void
+ {
+ $this->loginAs('manager');
+ $svc = $this->exposed();
+
+ $fresh = $svc->resolve('');
+ $id = (string) $fresh->conversation_id;
+ $this->assertNotSame('', $id, 'an empty id starts a new thread');
+
+ $again = $svc->resolve($id);
+ $this->assertSame($id, (string) $again->conversation_id, 'an owned id loads the same thread');
+
+ $unowned = $svc->resolve('someone-elses-id');
+ $this->assertNotSame('someone-elses-id', (string) $unowned->conversation_id, 'an un-owned id starts a new thread, never leaks');
+
+ $this->assertSame('manager', $svc->role(), 'the acting role comes from the identity');
+ }
+}
diff --git a/tests/Integration/Agent/AttachmentModelTest.php b/tests/Integration/Agent/AttachmentModelTest.php
new file mode 100644
index 0000000..6509b59
--- /dev/null
+++ b/tests/Integration/Agent/AttachmentModelTest.php
@@ -0,0 +1,156 @@
+ensureAttachmentTable();
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ self::dropAttachmentTable(); // never leave the side-loaded table for InstallerLifecycleTest
+ parent::tearDownAfterClass();
+ }
+
+ /** Insert a pending (unlinked) attachment row and return its id. */
+ private function seed(string $userId, string $orgId, array $over = []): string
+ {
+ return (new Agent_Model_Attachment())->insert($over + [
+ 'conversation_id' => null,
+ 'message_id' => null,
+ 'user_id' => $userId,
+ 'org_id' => $orgId,
+ 'disk' => 'local',
+ 'filename' => 'notes.txt',
+ 'mime_type' => 'text/plain',
+ 'file_size' => 12,
+ 'kind' => 'file',
+ ]);
+ }
+
+ // ----- pendingForUser (owner scoping + still-pending guard) ------------------------------------
+
+ #[Test]
+ public function pendingForUser_returns_only_the_callers_own_unlinked_rows(): void
+ {
+ $mine = $this->seed('user-a', 'org-1');
+ $theirs = $this->seed('user-b', 'org-1');
+ $linked = $this->seed('user-a', 'org-1');
+ // Mark one of mine as already sent (message_id set) → it must NOT come back.
+ (new Agent_Model_Attachment())->update(['message_id' => 'msg-1'], "attachment_id = '{$linked}'");
+
+ $rows = (new Agent_Model_Attachment())->pendingForUser([$mine, $theirs, $linked], 'user-a');
+ $ids = array_column($rows, 'attachment_id');
+
+ $this->assertContains($mine, $ids, 'my still-pending row resolves');
+ $this->assertNotContains($theirs, $ids, "another user's row is dropped");
+ $this->assertNotContains($linked, $ids, 'an already-sent (linked) row is dropped');
+ }
+
+ #[Test]
+ public function pendingForUser_with_no_ids_returns_empty_without_a_query(): void
+ {
+ $this->assertSame([], (new Agent_Model_Attachment())->pendingForUser([], 'user-a'));
+ $this->assertSame([], (new Agent_Model_Attachment())->pendingForUser(['', null], 'user-a'));
+ }
+
+ // ----- linkToMessage (owner-scoped, only still-pending) ---------------------------------------
+
+ #[Test]
+ public function linkToMessage_binds_only_owned_pending_rows_and_reports_the_count(): void
+ {
+ $mine = $this->seed('user-a', 'org-1');
+ $theirs = $this->seed('user-b', 'org-1');
+
+ $n = (new Agent_Model_Attachment())->linkToMessage([$mine, $theirs], 'conv-9', 'msg-9', 'user-a');
+ $this->assertSame(1, $n, 'only my row is linked (the other owner is never touched)');
+
+ $row = $this->db->fetchRow('SELECT conversation_id, message_id FROM agent_attachment WHERE attachment_id = ?', [$mine]);
+ $this->assertSame('conv-9', $row['conversation_id']);
+ $this->assertSame('msg-9', $row['message_id']);
+
+ $other = $this->db->fetchRow('SELECT message_id FROM agent_attachment WHERE attachment_id = ?', [$theirs]);
+ $this->assertNull($other['message_id'], "the other user's row is left pending");
+ }
+
+ #[Test]
+ public function linkToMessage_with_no_ids_is_a_noop(): void
+ {
+ $this->assertSame(0, (new Agent_Model_Attachment())->linkToMessage([], 'c', 'm', 'user-a'));
+ }
+
+ // ----- document extraction (real containers) --------------------------------------------------
+
+ #[Test]
+ public function extractText_pulls_readable_text_from_a_real_docx(): void
+ {
+ if (!class_exists('ZipArchive')) { $this->markTestSkipped('ZipArchive not available'); }
+
+ $tmp = tempnam(sys_get_temp_dir(), 'agtdocx') . '.docx';
+ $zip = new ZipArchive();
+ $zip->open($tmp, ZipArchive::CREATE);
+ $zip->addFromString(
+ 'word/document.xml',
+ ''
+ . 'Hello Tiger'
+ . 'Second line'
+ . ''
+ );
+ $zip->close();
+ $bytes = (string) file_get_contents($tmp);
+ @unlink($tmp);
+
+ $out = Agent_Model_Attachment::extractText('brief.docx', 'application/octet-stream', $bytes);
+ $this->assertStringContainsString('Hello Tiger', $out);
+ $this->assertStringContainsString('Second line', $out);
+ }
+
+ #[Test]
+ public function extractText_scans_shown_strings_from_a_minimal_pdf(): void
+ {
+ // A tiny uncompressed PDF content stream with a single Tj text-show operator.
+ $pdf = "%PDF-1.4\n"
+ . "stream\n"
+ . "BT /F1 12 Tf (Hello from a PDF) Tj ET\n"
+ . "endstream\n"
+ . "%%EOF";
+
+ $out = Agent_Model_Attachment::extractText('paper.pdf', 'application/pdf', $pdf);
+ $this->assertIsString($out);
+ $this->assertStringContainsString('Hello from a PDF', $out);
+ }
+
+ #[Test]
+ public function extractText_returns_null_for_a_store_only_binary_type(): void
+ {
+ // An epub is stored for the agent to ACT on, never parsed as text.
+ $this->assertNull(Agent_Model_Attachment::extractText('novel.epub', 'application/epub+zip', 'PK\x03\x04'));
+ }
+}
diff --git a/tests/Integration/Agent/EnsuresAttachmentTable.php b/tests/Integration/Agent/EnsuresAttachmentTable.php
new file mode 100644
index 0000000..3bdbb74
--- /dev/null
+++ b/tests/Integration/Agent/EnsuresAttachmentTable.php
@@ -0,0 +1,89 @@
+ getenv('TIGER_TEST_DB_HOST') ?: '127.0.0.1',
+ 'port' => (int) (getenv('TIGER_TEST_DB_PORT') ?: 3306),
+ 'dbname' => getenv('TIGER_TEST_DB_NAME'),
+ 'username' => getenv('TIGER_TEST_DB_USER') ?: 'root',
+ 'password' => getenv('TIGER_TEST_DB_PASS') ?: '',
+ 'charset' => 'utf8mb4',
+ ]);
+ $sep->query(
+ "CREATE TABLE IF NOT EXISTS `agent_attachment` (
+ `attachment_id` CHAR(36) NOT NULL,
+ `conversation_id` CHAR(36) NULL,
+ `message_id` CHAR(36) NULL,
+ `user_id` CHAR(36) NOT NULL,
+ `org_id` CHAR(36) NOT NULL,
+ `disk` VARCHAR(64) NOT NULL DEFAULT 'local',
+ `storage_key` VARCHAR(512) NULL,
+ `filename` VARCHAR(255) NOT NULL,
+ `mime_type` VARCHAR(128) NULL,
+ `file_size` BIGINT NULL,
+ `kind` VARCHAR(16) NOT NULL DEFAULT 'file',
+ `extract` MEDIUMTEXT NULL,
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_by` CHAR(36) NULL,
+ `updated_by` CHAR(36) NULL,
+ `created_at` DATETIME NOT NULL,
+ `updated_at` DATETIME NULL,
+ PRIMARY KEY (`attachment_id`),
+ KEY `ix_agent_attach_msg` (`conversation_id`, `message_id`),
+ KEY `ix_agent_attach_user` (`user_id`, `message_id`, `deleted`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+ $sep->closeConnection();
+ self::$attachmentTableReady = true;
+ }
+
+ /**
+ * Drop the side-loaded table again so it never persists into other suites — notably
+ * InstallerLifecycleTest, which installs the agent module for real and creates this table itself
+ * (a plain CREATE that would collide with a leftover). The using class calls this from
+ * tearDownAfterClass. Uses a separate connection (the shared adapter's txn is already torn down).
+ */
+ protected static function dropAttachmentTable(): void
+ {
+ if (!self::$attachmentTableReady) {
+ return;
+ }
+ $sep = Zend_Db::factory('Pdo_Mysql', [
+ 'host' => getenv('TIGER_TEST_DB_HOST') ?: '127.0.0.1',
+ 'port' => (int) (getenv('TIGER_TEST_DB_PORT') ?: 3306),
+ 'dbname' => getenv('TIGER_TEST_DB_NAME'),
+ 'username' => getenv('TIGER_TEST_DB_USER') ?: 'root',
+ 'password' => getenv('TIGER_TEST_DB_PASS') ?: '',
+ 'charset' => 'utf8mb4',
+ ]);
+ $sep->query('DROP TABLE IF EXISTS `agent_attachment`');
+ $sep->closeConnection();
+ self::$attachmentTableReady = false;
+ }
+}
diff --git a/tests/Integration/Agent/SettingsServiceTest.php b/tests/Integration/Agent/SettingsServiceTest.php
new file mode 100644
index 0000000..8bd72a2
--- /dev/null
+++ b/tests/Integration/Agent/SettingsServiceTest.php
@@ -0,0 +1,206 @@
+offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); }
+ parent::tearDown();
+ }
+
+ /** Put a Zend_Config in the registry (crypto optional) so Tiger_Crypto/Tiger_Agent read it. */
+ private function seedConfig(bool $withCrypto): void
+ {
+ $tiger = [];
+ if ($withCrypto) { $tiger['crypto'] = ['key' => self::CRYPTO_KEY]; }
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger], true));
+ }
+
+ /** Dispatch the service with an action + payload and hand back the response object. */
+ private function call(string $action, array $params = []): object
+ {
+ return (new Agent_Service_Settings(['action' => $action] + $params))->getResponse();
+ }
+
+ /** Read a single global config value straight from the DB (bypassing the registry cascade). */
+ private function dbConfig(string $key): ?string
+ {
+ $v = $this->db->fetchOne(
+ "SELECT config_value FROM config WHERE scope = 'global' AND scope_id = '' AND config_key = ? AND deleted = 0",
+ [$key]
+ );
+ return $v === false ? null : (string) $v;
+ }
+
+ // ----- ACL gate (deny-by-default) -------------------------------------------------------------
+
+ #[Test]
+ public function guest_and_plain_user_are_denied_both_actions(): void
+ {
+ foreach (['guest', 'user', 'manager'] as $role) {
+ $this->loginAs($role);
+ foreach (['save', 'models'] as $action) {
+ $res = $this->call($action);
+ $this->assertSame(0, (int) $res->result, "{$role} denied on {$action}");
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages), "{$role}/{$action} ACL denial");
+ }
+ }
+ }
+
+ // ----- save (validate -> transaction, config tier) --------------------------------------------
+
+ #[Test]
+ public function save_writes_the_switch_provider_model_and_mode_to_the_config_tier(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false);
+
+ $res = $this->call('save', [
+ 'enabled' => '1',
+ 'provider' => 'openai',
+ 'model' => ' gpt-4o ',
+ 'mode_max' => 'yolo',
+ ]);
+
+ $this->assertSame(1, (int) $res->result, 'admin save succeeds');
+ $this->assertSame('1', $this->dbConfig(Tiger_Agent::CFG_ENABLED));
+ $this->assertSame('openai', $this->dbConfig(Tiger_Agent::CFG_PROVIDER));
+ $this->assertSame('gpt-4o', $this->dbConfig(Tiger_Agent::CFG_MODEL), 'model is trimmed');
+ $this->assertSame('yolo', $this->dbConfig(Tiger_Agent::CFG_MODE_MAX));
+ $this->assertArrayHasKey('connected', (array) $res->data);
+ }
+
+ #[Test]
+ public function save_disabled_stores_zero_for_the_switch(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false);
+ $res = $this->call('save', ['provider' => 'anthropic']); // no `enabled` key → off
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('0', $this->dbConfig(Tiger_Agent::CFG_ENABLED));
+ }
+
+ #[Test]
+ public function save_falls_back_to_anthropic_for_an_unknown_provider(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false);
+ $res = $this->call('save', ['provider' => 'no-such-provider', 'enabled' => '1']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('anthropic', $this->dbConfig(Tiger_Agent::CFG_PROVIDER), 'stale/unknown provider → anthropic');
+ }
+
+ #[Test]
+ public function save_falls_back_to_auto_for_an_invalid_mode_ceiling(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false);
+ $res = $this->call('save', ['provider' => 'anthropic', 'mode_max' => 'ludicrous']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('auto', $this->dbConfig(Tiger_Agent::CFG_MODE_MAX), 'an unknown mode → auto');
+ }
+
+ // ----- the BYO-key convention -----------------------------------------------------------------
+
+ #[Test]
+ public function a_blank_key_field_writes_no_key_row_preserving_the_stored_secret(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(true);
+ $res = $this->call('save', ['provider' => 'anthropic', 'api_key' => ' ']); // blank after trim
+ $this->assertSame(1, (int) $res->result);
+ $this->assertNull($this->dbConfig(Tiger_Agent::CFG_KEY_ENC), 'no key row written when the field is blank');
+ }
+
+ #[Test]
+ public function a_new_key_is_encrypted_at_rest_never_stored_in_plaintext(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(true);
+
+ $secret = 'sk-super-secret-byo-key';
+ $res = $this->call('save', ['provider' => 'anthropic', 'api_key' => $secret]);
+ $this->assertSame(1, (int) $res->result);
+
+ $blob = $this->dbConfig(Tiger_Agent::CFG_KEY_ENC);
+ $this->assertNotNull($blob, 'a key row was written');
+ $this->assertStringNotContainsString($secret, $blob, 'the plaintext key never touches the DB');
+ $this->assertSame($secret, Tiger_Crypto::decrypt($blob), 'and it round-trips back through Tiger_Crypto');
+ }
+
+ #[Test]
+ public function saving_a_key_without_encryption_configured_is_a_clean_error(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false); // no tiger.crypto.key → Tiger_Crypto::isConfigured() is false
+ $res = $this->call('save', ['provider' => 'anthropic', 'api_key' => 'sk-cannot-store']);
+
+ $this->assertSame(0, (int) $res->result, 'the save refuses to store a key it cannot encrypt');
+ $this->assertNull($this->dbConfig(Tiger_Agent::CFG_KEY_ENC), 'and no key row was written');
+ }
+
+ // ----- models() -------------------------------------------------------------------------------
+
+ #[Test]
+ public function models_returns_the_keyless_static_fallback_for_a_provider(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false); // no stored key → apiKey() is '' → the static fallback (no network)
+
+ $res = $this->call('models', ['provider' => 'anthropic']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertFalse($res->data['live'], 'no key → not a live listing');
+ $this->assertNotEmpty($res->data['models']);
+ $this->assertArrayHasKey('id', $res->data['models'][0]);
+ $this->assertArrayHasKey('label', $res->data['models'][0]);
+ }
+
+ #[Test]
+ public function models_sanitizes_and_falls_back_for_an_unknown_provider(): void
+ {
+ $this->loginAs('admin');
+ $this->seedConfig(false);
+ // Digits/junk are stripped and an unknown key falls back to anthropic — never a hard failure.
+ $res = $this->call('models', ['provider' => 'Op3nAI!!']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertNotEmpty($res->data['models']);
+ }
+}
diff --git a/tests/Integration/Analytics/AdminControllerTest.php b/tests/Integration/Analytics/AdminControllerTest.php
new file mode 100644
index 0000000..c407b3a
--- /dev/null
+++ b/tests/Integration/Analytics/AdminControllerTest.php
@@ -0,0 +1,77 @@
+loginAs('admin');
+ $this->dispatchAction(Analytics_AdminController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Analytics', (string) $view->title);
+ $this->assertInstanceOf(\Analytics_Form_Settings::class, $view->form);
+ $this->assertFalse($view->enabled);
+ $this->assertIsArray($view->ga);
+ $this->assertFalse((bool) $view->ga['connected']);
+ $this->assertFalse((bool) $view->ga['configurable']);
+ $this->assertStringContainsString('/analytics/admin/callback', (string) $view->ga['redirect_uri']);
+ }
+
+ #[Test]
+ public function connect_redirects_back_with_an_error_when_not_configurable(): void
+ {
+ // No GA4 property id set → not configurable → flash + redirect to the settings screen.
+ $this->loginAs('admin');
+ $this->dispatchAction(Analytics_AdminController::class, 'connect', [], 'GET');
+
+ $this->assertStringContainsString('/analytics/admin', $this->redirectLocation());
+ }
+
+ #[Test]
+ public function callback_broker_mode_flashes_and_redirects_on_a_denied_grant(): void
+ {
+ // Broker mode is the default; an ?error param means the user denied consent → flash + redirect.
+ $this->loginAs('admin');
+ $this->dispatchAction(Analytics_AdminController::class, 'callback', ['error' => 'access_denied'], 'GET');
+
+ $this->assertStringContainsString('/analytics/admin', $this->redirectLocation());
+ }
+
+ #[Test]
+ public function disconnect_forgets_the_connection_and_redirects(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Analytics_AdminController::class, 'disconnect', [], 'GET');
+
+ $this->assertStringContainsString('/analytics/admin', $this->redirectLocation());
+ }
+
+ #[Test]
+ public function dashboard_renders_the_reports_shell(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Analytics_AdminController::class, 'dashboard', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Dashboard', (string) $view->title);
+ $this->assertFalse((bool) $view->connected);
+ }
+}
diff --git a/tests/Integration/Backup/IndexControllerTest.php b/tests/Integration/Backup/IndexControllerTest.php
new file mode 100644
index 0000000..89031a5
--- /dev/null
+++ b/tests/Integration/Backup/IndexControllerTest.php
@@ -0,0 +1,55 @@
+loginAs('admin');
+ $res = $this->dispatchAction(Backup_IndexController::class, 'index');
+ $this->assertSame(200, $res->getHttpResponseCode(), 'the backup admin screen dispatches without error');
+ }
+
+ #[Test]
+ public function a_download_of_an_unknown_backup_id_does_not_stream(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->dispatchAction(Backup_IndexController::class, 'download', ['id' => 'no-such-backup']);
+ // No matching row → the action returns without a file body (a 404-ish empty stream), never a fatal.
+ $this->assertNotSame('', (string) $res->getHttpResponseCode());
+ }
+
+ #[Test]
+ public function a_restore_without_the_typed_confirmation_is_refused(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->dispatchAction(Backup_IndexController::class, 'upload', ['confirm' => ''], 'POST');
+ $json = json_decode($res->getBody(), true);
+ $this->assertSame(0, (int) ($json['result'] ?? -1), 'a restore needs the literal RESTORE confirmation');
+ }
+
+ #[Test]
+ public function a_confirmed_restore_with_no_uploaded_file_reports_upload_failed(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->dispatchAction(Backup_IndexController::class, 'upload', ['confirm' => 'RESTORE'], 'POST');
+ $json = json_decode($res->getBody(), true);
+ $this->assertSame(0, (int) ($json['result'] ?? -1), 'no file → refused');
+ $this->assertStringContainsString('upload', json_encode($json), 'the failure is an upload error');
+ }
+}
diff --git a/tests/Integration/Blog/ControllersTest.php b/tests/Integration/Blog/ControllersTest.php
new file mode 100644
index 0000000..cab77eb
--- /dev/null
+++ b/tests/Integration/Blog/ControllersTest.php
@@ -0,0 +1,125 @@
+insert(array_merge([
+ 'type' => Blog_Model_Post::TYPE_ARTICLE,
+ 'org_id' => 'org-test',
+ 'locale' => 'en',
+ 'title' => 'Hello World',
+ 'slug' => 'hello-world',
+ 'body' => 'Body copy.',
+ 'format' => Tiger_Model_Page::FORMAT_HTML,
+ 'status' => Blog_Model_Post::STATUS_PUBLISHED,
+ 'published_at' => '2026-01-01 00:00:00',
+ 'meta' => json_encode(Blog_Model_Post::META_DEFAULTS),
+ ], $overrides));
+ }
+
+ // ----- public front-end -----------------------------------------------------------------------
+
+ #[Test]
+ public function public_index_lists_articles(): void
+ {
+ $this->dispatchAction(Blog_IndexController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertSame('Blog', (string) $view->title);
+ $this->assertSame('Latest', (string) $view->heading);
+ $this->assertIsArray($view->posts);
+ }
+
+ #[Test]
+ public function public_view_404s_an_unknown_article(): void
+ {
+ $this->expectException(Zend_Controller_Action_Exception::class);
+ $this->dispatchAction(Blog_IndexController::class, 'view', ['slug' => 'no-such-article'], 'GET');
+ }
+
+ #[Test]
+ public function public_category_404s_an_unknown_term(): void
+ {
+ $this->expectException(Zend_Controller_Action_Exception::class);
+ $this->dispatchAction(Blog_IndexController::class, 'category', ['slug' => 'ghost'], 'GET');
+ }
+
+ #[Test]
+ public function public_feed_sets_the_rss_content_type(): void
+ {
+ $res = $this->dispatchAction(Blog_IndexController::class, 'feed', [], 'GET');
+
+ $ct = '';
+ foreach ($res->getHeaders() as $h) {
+ if (strcasecmp($h['name'], 'Content-Type') === 0) { $ct = (string) $h['value']; }
+ }
+ $this->assertStringContainsString('application/rss+xml', $ct);
+ $this->assertIsArray($this->controller()->view->posts);
+ }
+
+ // ----- admin authoring ------------------------------------------------------------------------
+
+ #[Test]
+ public function admin_index_renders_the_datatables_shell(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Blog_PostController::class, 'index', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Articles', (string) $view->title);
+ $this->assertTrue((bool) $view->useDataTables);
+ }
+
+ #[Test]
+ public function admin_edit_renders_a_blank_editor_for_a_new_article(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Blog_PostController::class, 'edit', [], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('New Article', (string) $view->title);
+ $this->assertInstanceOf(\Blog_Form_Post::class, $view->form);
+ $this->assertNull($view->post);
+ }
+
+ #[Test]
+ public function admin_edit_prefills_the_editor_for_an_existing_article(): void
+ {
+ $id = $this->seedArticle(['title' => 'My Draft', 'slug' => 'my-draft']);
+ $this->loginAs('admin');
+ $this->dispatchAction(Blog_PostController::class, 'edit', ['id' => $id], 'GET');
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Edit Article', (string) $view->title);
+ $this->assertNotNull($view->post);
+ // recentForPage() returns a Zend_Db_Table_Rowset (Traversable), not a plain array.
+ $this->assertInstanceOf(\Traversable::class, $view->versions);
+ $this->assertSame('My Draft', $view->form->getValues()['title']);
+ }
+}
diff --git a/tests/Integration/Cms/CmsControllerTest.php b/tests/Integration/Cms/CmsControllerTest.php
new file mode 100644
index 0000000..1f68eb6
--- /dev/null
+++ b/tests/Integration/Cms/CmsControllerTest.php
@@ -0,0 +1,276 @@
+title`,
+ * `useDataTables`, form, and the theme-template / version / palette vars the .phtml then draws.
+ *
+ * A fixture theme (a temp dir with a `content/` page + a `components/` block + a manifest) is wired in
+ * via the `Tiger_ThemeDir` registry seam so the page-list's "customize a theme template" branch and the
+ * GrapesJS design canvas's block/menu-preview branches actually run (with no theme, `Tiger_Theme` short-
+ * circuits to empty). Zend_Session unit-test mode is on so the FlashMessenger the base controller wires
+ * in init() has no live session to reach for.
+ */
+#[CoversClass(Cms_PageController::class)]
+#[CoversClass(Cms_MenuController::class)]
+#[CoversClass(Cms_SettingsController::class)]
+#[CoversClass(Cms_IndexController::class)]
+final class CmsControllerTest extends ControllerTestCase
+{
+ private bool $priorUnitTestMode;
+ private string $themeDir;
+ private bool $hadThemeDir = false;
+ private $priorThemeDir = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->priorUnitTestMode = Zend_Session::$_unitTestEnabled;
+ Zend_Session::$_unitTestEnabled = true;
+ $_SESSION = [];
+
+ // designAction redirects a missing page via the redirector; keep gotoUrl from exit()-ing so the
+ // Location header is assertable instead of ending the PHP process.
+ \Zend_Controller_Action_HelperBroker::getStaticHelper('redirector')->setExit(false);
+
+ // A throwaway theme on disk, registered as the active theme so Tiger_Theme::pages()/components()/
+ // manifest() return real data — driving the theme-template + builder-block branches.
+ $this->themeDir = sys_get_temp_dir() . '/w6-cms-theme-' . getmypid();
+ @mkdir($this->themeDir . '/content', 0777, true);
+ @mkdir($this->themeDir . '/components', 0777, true);
+ file_put_contents($this->themeDir . '/theme.json', json_encode([
+ 'key' => 'testtheme', 'name' => 'Test Theme', 'canvasCss' => ['/theme/canvas.css'],
+ ]));
+ file_put_contents(
+ $this->themeDir . '/content/welcome.phtml',
+ "\nWelcome
"
+ );
+ file_put_contents(
+ $this->themeDir . '/components/cta.phtml',
+ "\nCTA
"
+ );
+
+ $reg = Zend_Registry::getInstance();
+ $this->hadThemeDir = $reg->offsetExists('Tiger_ThemeDir');
+ if ($this->hadThemeDir) { $this->priorThemeDir = Zend_Registry::get('Tiger_ThemeDir'); }
+ Zend_Registry::set('Tiger_ThemeDir', $this->themeDir);
+ }
+
+ protected function tearDown(): void
+ {
+ $reg = Zend_Registry::getInstance();
+ if ($this->hadThemeDir) { Zend_Registry::set('Tiger_ThemeDir', $this->priorThemeDir); }
+ elseif ($reg->offsetExists('Tiger_ThemeDir')) { $reg->offsetUnset('Tiger_ThemeDir'); }
+ $this->rmrf($this->themeDir);
+
+ $_SESSION = [];
+ Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode;
+ parent::tearDown();
+ }
+
+ private function rmrf(string $dir): void
+ {
+ if (!is_dir($dir)) { @unlink($dir); return; }
+ foreach (scandir($dir) as $e) {
+ if ($e === '.' || $e === '..') { continue; }
+ $p = $dir . '/' . $e;
+ is_dir($p) && !is_link($p) ? $this->rmrf($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+
+ private function seedPage(array $overrides): string
+ {
+ return (new Tiger_Model_Page())->insert(array_merge([
+ 'type' => Tiger_Model_Page::TYPE_PAGE,
+ 'locale' => 'en',
+ 'title' => 'Seed',
+ 'body' => '',
+ 'format' => Tiger_Model_Page::FORMAT_HTML,
+ 'status' => Tiger_Model_Page::STATUS_DRAFT,
+ ], $overrides));
+ }
+
+ // ----- Cms_PageController -------------------------------------------------------------------
+
+ #[Test]
+ public function page_index_renders_the_content_list_shell_with_theme_templates(): void
+ {
+ $this->loginAs('admin');
+ // A page already claims the theme template's slug → the "already customized" flag branch.
+ $id = $this->seedPage(['title' => 'Welcome', 'slug' => 'welcome', 'page_key' => 'welcome']);
+
+ $res = $this->dispatchAction(Cms_PageController::class, 'index', [], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+
+ $view = $this->controller()->view;
+ $this->assertSame('Content — Tiger Admin', $view->title);
+ $this->assertTrue($view->useDataTables);
+ $this->assertSame('Test Theme', $view->themeName);
+ $this->assertNotEmpty($view->themeTemplates, 'the active theme templates are surfaced');
+
+ $welcome = null;
+ foreach ($view->themeTemplates as $t) { if ($t['slug'] === 'welcome') { $welcome = $t; } }
+ $this->assertNotNull($welcome, 'the welcome template appears');
+ $this->assertSame($id, $welcome['page_id'], 'the template is flagged customized (a page claims its slug)');
+ }
+
+ #[Test]
+ public function page_edit_with_no_id_renders_a_blank_new_form(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->dispatchAction(Cms_PageController::class, 'edit', [], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('New', $view->title);
+ $this->assertNull($view->page, 'no page to edit');
+ $this->assertSame([], $view->versions);
+ $this->assertNotNull($view->form);
+ }
+
+ #[Test]
+ public function page_edit_with_an_id_populates_the_form_from_the_row_and_its_meta(): void
+ {
+ $this->loginAs('admin');
+ $meta = json_encode([
+ 'seo' => ['description' => 'A seo blurb'],
+ 'head_html' => '',
+ 'body_scripts' => '',
+ ]);
+ $id = $this->seedPage(['title' => 'Editable', 'slug' => 'editable', 'page_key' => 'editable', 'meta' => $meta]);
+
+ $res = $this->dispatchAction(Cms_PageController::class, 'edit', ['id' => $id], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+
+ $view = $this->controller()->view;
+ $this->assertStringContainsString('Edit', $view->title);
+ $this->assertNotNull($view->page, 'the row loaded');
+ $this->assertSame('Editable', $view->page->title);
+ // _editValues fed the form; the meta.seo.description round-trips into the form value.
+ $this->assertSame('A seo blurb', $view->form->getValue('meta_description'));
+ }
+
+ #[Test]
+ public function page_design_redirects_when_the_page_is_missing(): void
+ {
+ $this->loginAs('admin');
+ $this->dispatchAction(Cms_PageController::class, 'design', ['id' => 'no-such-page'], 'GET');
+ $this->assertStringContainsString('/cms/page', $this->redirectLocation(), 'a bad id bounces back to the list');
+ }
+
+ #[Test]
+ public function page_design_renders_the_builder_with_theme_blocks_and_the_builder_project(): void
+ {
+ $this->loginAs('admin');
+ $builder = json_encode(['pages' => [['frames' => []]]]);
+ $id = $this->seedPage([
+ 'title' => 'Designed', 'slug' => 'designed', 'page_key' => 'designed',
+ 'format' => Tiger_Model_Page::FORMAT_BUILDER,
+ 'meta' => json_encode(['builder' => json_decode($builder, true)]),
+ ]);
+
+ $res = $this->dispatchAction(Cms_PageController::class, 'design', ['id' => $id], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+
+ $view = $this->controller()->view;
+ $this->assertSame('Designed', $view->title);
+ $this->assertNotNull($view->projectData, 'the lossless builder project is passed to the canvas');
+ $this->assertNotEmpty($view->themeBlocks, 'the active theme components seed the block palette');
+ $this->assertSame(['/theme/canvas.css'], $view->canvasCss, 'the manifest canvasCss is loaded into the canvas');
+ $this->assertIsArray($view->menus, 'menus are pre-rendered for the live Menu-component preview');
+ }
+
+ // ----- Cms_MenuController -------------------------------------------------------------------
+
+ #[Test]
+ public function menu_index_renders_the_list_shell(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->dispatchAction(Cms_MenuController::class, 'index', [], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+ $view = $this->controller()->view;
+ $this->assertSame('Menus — Tiger Admin', $view->title);
+ $this->assertTrue($view->useDataTables);
+ }
+
+ #[Test]
+ public function menu_edit_new_has_an_empty_tree_and_menu_edit_with_a_key_builds_the_page_palette(): void
+ {
+ $this->loginAs('admin');
+
+ // New menu (no key): empty tree, still renders the form + palette.
+ $this->dispatchAction(Cms_MenuController::class, 'edit', [], 'GET');
+ $newView = $this->controller()->view;
+ $this->assertStringContainsString('New Menu', $newView->title);
+ $this->assertSame([], $newView->tree);
+ $this->assertNotNull($newView->form);
+
+ // Existing menu (a key) + a keyed published page → the "Pages" palette source is populated.
+ $this->seedPage(['title' => 'Palette Page', 'slug' => 'palette', 'page_key' => 'palette', 'status' => Tiger_Model_Page::STATUS_PUBLISHED]);
+ $this->dispatchAction(Cms_MenuController::class, 'edit', ['key' => 'primary'], 'GET');
+ $editView = $this->controller()->view;
+ $this->assertStringContainsString('Edit Menu', $editView->title);
+ $this->assertSame('primary', $editView->menuKey);
+ $keys = array_column($editView->pages, 'page_key');
+ $this->assertContains('palette', $keys, 'a keyed published page is offered in the palette');
+ }
+
+ // ----- Cms_SettingsController ---------------------------------------------------------------
+
+ #[Test]
+ public function settings_index_prefills_the_form_from_the_live_config(): void
+ {
+ $this->loginAs('admin');
+ Zend_Registry::set('Zend_Config', new Zend_Config([
+ 'tiger' => ['site' => ['name' => 'Configured Site', 'home_page' => 'home-slug']],
+ ]));
+
+ $res = $this->dispatchAction(Cms_SettingsController::class, 'index', [], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+
+ $view = $this->controller()->view;
+ $this->assertSame('Settings — Tiger Admin', $view->title);
+ $this->assertSame('Configured Site', $view->form->getValue('site_name'));
+ $this->assertSame('home-slug', $view->form->getValue('home_page'));
+ }
+
+ #[Test]
+ public function settings_index_falls_back_to_the_default_site_name_when_config_is_empty(): void
+ {
+ $this->loginAs('admin');
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => []]));
+
+ $this->dispatchAction(Cms_SettingsController::class, 'index', [], 'GET');
+ $view = $this->controller()->view;
+ $this->assertSame('Tiger', $view->form->getValue('site_name'), 'the built-in default fills in when unset');
+ }
+
+ // ----- Cms_IndexController (public marketing face) ------------------------------------------
+
+ #[Test]
+ public function the_public_cms_landing_dispatches_cleanly(): void
+ {
+ // Guest-allowed marketing page — no model, no forward, just the view. It must dispatch without error.
+ $res = $this->dispatchAction(Cms_IndexController::class, 'index', [], 'GET');
+ $this->assertSame(200, $res->getHttpResponseCode());
+ }
+}
diff --git a/tests/Integration/Cms/FormsTest.php b/tests/Integration/Cms/FormsTest.php
new file mode 100644
index 0000000..02af2b7
--- /dev/null
+++ b/tests/Integration/Cms/FormsTest.php
@@ -0,0 +1,128 @@
+offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); }
+ parent::tearDown();
+ }
+
+ private function seedPublishedPage(): void
+ {
+ (new Tiger_Model_Page())->insert([
+ 'type' => Tiger_Model_Page::TYPE_PAGE,
+ 'locale' => 'en',
+ 'title' => 'Home',
+ 'slug' => 'home',
+ 'page_key' => 'home',
+ 'body' => '',
+ 'format' => Tiger_Model_Page::FORMAT_HTML,
+ 'status' => Tiger_Model_Page::STATUS_PUBLISHED,
+ ]);
+ }
+
+ // ----- Cms_Form_Page ------------------------------------------------------------------------
+
+ #[Test]
+ public function page_form_declares_its_schema_and_requires_only_a_title(): void
+ {
+ $form = new Cms_Form_Page();
+ foreach (['page_id', 'title', 'slug', 'page_key', 'type', 'format', 'status', 'locale', 'body', 'meta_description'] as $name) {
+ $this->assertNotNull($form->getElement($name), "the $name element is declared");
+ }
+ $this->assertTrue($form->getElement('title')->isRequired(), 'title is the one hard-required field');
+ $this->assertFalse($form->getElement('slug')->isRequired(), 'slug is optional (derived when blank)');
+ }
+
+ #[Test]
+ public function page_form_validates_a_minimal_page_and_rejects_a_blank_title(): void
+ {
+ $form = new Cms_Form_Page();
+ $this->assertTrue(
+ $form->isValid(['title' => 'Hello', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en']),
+ 'a titled page validates'
+ );
+
+ $bad = new Cms_Form_Page();
+ $this->assertFalse($bad->isValid(['title' => '', 'type' => 'page', 'format' => 'html', 'status' => 'draft', 'locale' => 'en']));
+ $this->assertArrayHasKey('title', $bad->getMessages());
+ }
+
+ // ----- Cms_Form_MenuItem --------------------------------------------------------------------
+
+ #[Test]
+ public function menu_item_form_disables_csrf_requires_a_label_and_lists_linkable_pages(): void
+ {
+ $this->seedPublishedPage(); // → the page_key dropdown option-loading branch runs
+ $form = new Cms_Form_MenuItem();
+
+ $this->assertNull($form->getElement('_csrf'), 'the menu builder form carries no CSRF token');
+ $this->assertTrue($form->getElement('label')->isRequired(), 'label is required');
+ foreach (['url', 'icon', 'css_class', 'link_target', 'resource', 'privilege', 'status'] as $name) {
+ $this->assertNotNull($form->getElement($name), "the $name element is declared");
+ }
+ $options = $form->getElement('page_key')->getMultiOptions();
+ $this->assertArrayHasKey('home', $options, 'the seeded published page is offered as a link target');
+ }
+
+ #[Test]
+ public function menu_item_form_rejects_a_blank_label(): void
+ {
+ $form = new Cms_Form_MenuItem();
+ $this->assertFalse($form->isValid(['label' => '']));
+ $this->assertArrayHasKey('label', $form->getMessages());
+ }
+
+ // ----- Cms_Form_Settings --------------------------------------------------------------------
+
+ #[Test]
+ public function settings_form_requires_a_site_name_and_lists_published_pages_as_home_options(): void
+ {
+ $this->seedPublishedPage();
+ $form = new Cms_Form_Settings();
+
+ $this->assertTrue($form->getElement('site_name')->isRequired());
+ $home = $form->getElement('home_page')->getMultiOptions();
+ $this->assertArrayHasKey('', $home, 'the built-in landing is the empty option');
+ $this->assertContains('Home (en)', $home, 'a published page is a home-page choice');
+
+ $this->assertTrue($form->isValid(['site_name' => 'My Site', 'home_page' => '']));
+ $bad = new Cms_Form_Settings();
+ $this->assertFalse($bad->isValid(['site_name' => '', 'home_page' => '']));
+ $this->assertArrayHasKey('site_name', $bad->getMessages());
+ }
+}
diff --git a/tests/Integration/Cms/PageServiceExtraTest.php b/tests/Integration/Cms/PageServiceExtraTest.php
new file mode 100644
index 0000000..c2a0180
--- /dev/null
+++ b/tests/Integration/Cms/PageServiceExtraTest.php
@@ -0,0 +1,203 @@
+ to keep the SAFE builder format).
+ *
+ * forkTheme + saveDesign both defer to Tiger_Model_Page::save() (its own write+version transaction), so —
+ * like the Wave 3 save/restore tests — these commit the harness transaction first (escapeTxn) and lean on
+ * the tearDown scrub. A throwaway theme (a temp dir + a `content/` template) is wired in via the
+ * `Tiger_ThemeDir` seam so forkTheme has a real template to fork.
+ */
+#[CoversClass(Cms_Service_Page::class)]
+final class PageServiceExtraTest extends IntegrationTestCase
+{
+ private string $themeDir;
+ private bool $hadThemeDir = false;
+ private $priorThemeDir = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->themeDir = sys_get_temp_dir() . '/w6-cms-forktheme-' . getmypid();
+ @mkdir($this->themeDir . '/content', 0777, true);
+ file_put_contents($this->themeDir . '/theme.json', json_encode(['key' => 'testtheme', 'name' => 'Test Theme']));
+ file_put_contents(
+ $this->themeDir . '/content/about.phtml',
+ "\nAbout
"
+ );
+
+ $reg = Zend_Registry::getInstance();
+ $this->hadThemeDir = $reg->offsetExists('Tiger_ThemeDir');
+ if ($this->hadThemeDir) { $this->priorThemeDir = Zend_Registry::get('Tiger_ThemeDir'); }
+ Zend_Registry::set('Tiger_ThemeDir', $this->themeDir);
+ }
+
+ protected function tearDown(): void
+ {
+ try {
+ $this->db->query('DELETE FROM page_version');
+ $this->db->query('DELETE FROM page_redirect');
+ $this->db->query('DELETE FROM page');
+ } catch (\Throwable $e) {
+ // ignore
+ }
+ $reg = Zend_Registry::getInstance();
+ if ($this->hadThemeDir) { Zend_Registry::set('Tiger_ThemeDir', $this->priorThemeDir); }
+ elseif ($reg->offsetExists('Tiger_ThemeDir')) { $reg->offsetUnset('Tiger_ThemeDir'); }
+ $this->rmrf($this->themeDir);
+ parent::tearDown();
+ }
+
+ private function rmrf(string $dir): void
+ {
+ if (!is_dir($dir)) { @unlink($dir); return; }
+ foreach (scandir($dir) as $e) {
+ if ($e === '.' || $e === '..') { continue; }
+ $p = $dir . '/' . $e;
+ is_dir($p) && !is_link($p) ? $this->rmrf($p) : @unlink($p);
+ }
+ @rmdir($dir);
+ }
+
+ private function call(string $action, array $params = []): object
+ {
+ return (new Cms_Service_Page(['action' => $action] + $params))->getResponse();
+ }
+
+ private function escapeTxn(): void
+ {
+ $this->db->commit();
+ }
+
+ // ----- forkTheme ----------------------------------------------------------------------------
+
+ #[Test]
+ public function fork_theme_is_denied_to_a_guest(): void
+ {
+ $this->login('anon', 'org-test', 'guest');
+ $res = $this->call('forkTheme', ['slug' => 'about']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages), 'guest denied');
+ }
+
+ #[Test]
+ public function fork_theme_errors_when_the_template_is_unavailable(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('forkTheme', ['slug' => 'no-such-template']);
+ $this->assertSame(0, (int) $res->result, 'an unknown slug is a clean error, not a crash');
+ }
+
+ #[Test]
+ public function fork_theme_creates_an_editable_page_overriding_the_file(): void
+ {
+ $this->loginAs('admin');
+ $this->escapeTxn();
+
+ $res = $this->call('forkTheme', ['slug' => 'about']);
+ $this->assertSame(1, (int) $res->result, 'the template forked into a page');
+ $id = $res->data['page_id'];
+ $this->assertStringContainsString('/cms/page/edit/id/', $res->data['edit_url']);
+
+ $row = (new Tiger_Model_Page())->findById($id);
+ $this->assertSame('about', $row->slug, 'the fork claims the template slug (so it overrides the file)');
+ $this->assertSame('About Us', $row->title, 'title comes from the template hint');
+ $this->assertStringContainsString('About', (string) $row->body);
+ $this->assertSame(Tiger_Model_Page::STATUS_PUBLISHED, $row->status);
+ // Pure-markup body → html (opens in the visual builder); origin tagged in meta.
+ $this->assertSame(Tiger_Model_Page::FORMAT_HTML, $row->format);
+ $meta = json_decode((string) $row->meta, true);
+ $this->assertSame('theme', $meta['source'], 'the origin is recorded for a later revert');
+ $this->assertSame('testtheme', $meta['source_key']);
+ }
+
+ #[Test]
+ public function fork_theme_returns_the_existing_page_when_the_slug_is_already_customized(): void
+ {
+ $this->loginAs('admin');
+ $this->escapeTxn();
+
+ // First fork creates the row; a second fork must find it and return it (idempotent — no duplicate).
+ $first = $this->call('forkTheme', ['slug' => 'about']);
+ $existingId = $first->data['page_id'];
+
+ $second = $this->call('forkTheme', ['slug' => 'about']);
+ $this->assertSame(1, (int) $second->result);
+ $this->assertSame($existingId, $second->data['page_id'], 'the already-customized page is returned, not re-forked');
+ $this->assertStringContainsString('exists', json_encode($second->messages));
+
+ $count = (int) $this->db->fetchOne("SELECT COUNT(*) FROM page WHERE slug = 'about' AND deleted = 0");
+ $this->assertSame(1, $count, 'exactly one page claims the slug');
+ }
+
+ // ----- saveDesign ---------------------------------------------------------------------------
+
+ #[Test]
+ public function save_design_is_denied_to_a_guest(): void
+ {
+ $this->login('anon', 'org-test', 'guest');
+ $res = $this->call('saveDesign', ['page_id' => 'x', 'html' => '']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function save_design_errors_on_a_missing_page_id(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('saveDesign', ['page_id' => '', 'html' => '']);
+ $this->assertSame(0, (int) $res->result, 'no page_id is refused up front');
+ }
+
+ #[Test]
+ public function save_design_errors_when_the_page_does_not_exist(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('saveDesign', ['page_id' => 'no-such-page', 'html' => '']);
+ $this->assertSame(0, (int) $res->result, 'a non-existent row is a clean error');
+ }
+
+ #[Test]
+ public function save_design_stores_the_builder_body_strips_script_and_keeps_the_project(): void
+ {
+ $this->loginAs('admin');
+ $id = (new Tiger_Model_Page())->insert([
+ 'type' => Tiger_Model_Page::TYPE_PAGE, 'locale' => 'en', 'title' => 'Canvas',
+ 'slug' => 'canvas', 'page_key' => 'canvas', 'body' => '', 'format' => Tiger_Model_Page::FORMAT_HTML,
+ 'status' => Tiger_Model_Page::STATUS_DRAFT,
+ ]);
+ $this->escapeTxn();
+
+ $res = $this->call('saveDesign', [
+ 'page_id' => $id,
+ 'html' => '',
+ 'css' => '.hero{color:red}',
+ 'project' => json_encode(['pages' => [['name' => 'p']]]),
+ ]);
+ $this->assertSame(1, (int) $res->result);
+
+ $row = (new Tiger_Model_Page())->findById($id);
+ $this->assertSame(Tiger_Model_Page::FORMAT_BUILDER, $row->format, 'the builder format is set');
+ $this->assertStringContainsString('