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', + "\n

Welcome

" + ); + file_put_contents( + $this->themeDir . '/components/cta.phtml', + "\n
CTA
" + ); + + $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', + "\n

About

" + ); + + $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' => '
Hero
', + '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('