diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c8052cb..063450c 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: '18'
+ MIN_COVERAGE: '35'
steps:
- uses: actions/checkout@v4
diff --git a/library/Tiger/Db/Migrator.php b/library/Tiger/Db/Migrator.php
index 98b1411..29813b4 100644
--- a/library/Tiger/Db/Migrator.php
+++ b/library/Tiger/Db/Migrator.php
@@ -34,17 +34,26 @@ class Tiger_Db_Migrator
/** @var string[] directories to scan for migration files, in precedence order */
private $paths;
+ /** @var string ledger table recording applied versions (default `tiger_migration`) */
+ private $ledger;
+
/**
* Construct the migrator over a DB adapter and a set of migration directories.
*
* @param Zend_Db_Adapter_Abstract $db
- * @param string[] $paths migration directories (existing ones; missing are ignored)
+ * @param string[] $paths migration directories (existing ones; missing are ignored)
+ * @param string $ledgerTable the applied-versions ledger table. Defaults to the
+ * canonical `tiger_migration`; override it to run a migrator
+ * against an ISOLATED ledger (so a migration test's fixture
+ * versions never collide with — or roll back — the real
+ * schema's applied set).
* @return void
*/
- public function __construct(Zend_Db_Adapter_Abstract $db, array $paths)
+ public function __construct(Zend_Db_Adapter_Abstract $db, array $paths, $ledgerTable = 'tiger_migration')
{
- $this->db = $db;
- $this->paths = array_values(array_filter($paths, 'is_dir'));
+ $this->db = $db;
+ $this->paths = array_values(array_filter($paths, 'is_dir'));
+ $this->ledger = (string) $ledgerTable;
}
/**
@@ -68,7 +77,7 @@ public function migrate($log = null)
$this->run($stmt);
}
// Record only after every statement succeeded (see class caveat).
- $this->db->insert('tiger_migration', [
+ $this->db->insert($this->ledger, [
'version' => $version,
'name' => $m['name'],
'applied_at' => date('Y-m-d H:i:s'),
@@ -106,7 +115,7 @@ public function rollback($steps = 1, $log = null)
foreach ($m['down'] as $stmt) {
$this->run($stmt);
}
- $this->db->delete('tiger_migration', $this->db->quoteInto('version = ?', $version));
+ $this->db->delete($this->ledger, $this->db->quoteInto('version = ?', $version));
$done[$version] = $m['name'];
}
return $done;
@@ -179,7 +188,7 @@ private function discover()
/** @return array [version => true] of already-applied versions */
private function appliedVersions()
{
- $rows = $this->db->fetchCol('SELECT version FROM tiger_migration');
+ $rows = $this->db->fetchCol('SELECT version FROM ' . $this->db->quoteIdentifier($this->ledger));
return array_fill_keys($rows, true);
}
@@ -187,12 +196,12 @@ private function appliedVersions()
private function ensureTrackingTable()
{
$this->db->query(
- "CREATE TABLE IF NOT EXISTS `tiger_migration` (
+ 'CREATE TABLE IF NOT EXISTS ' . $this->db->quoteIdentifier($this->ledger) . ' (
`version` VARCHAR(64) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`applied_at` DATETIME NOT NULL,
PRIMARY KEY (`version`)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
diff --git a/tests/COVERAGE-PLAN.md b/tests/COVERAGE-PLAN.md
index f88d915..6177507 100644
--- a/tests/COVERAGE-PLAN.md
+++ b/tests/COVERAGE-PLAN.md
@@ -578,13 +578,30 @@ Then **4 parallel agents** (own worktree + own DB `tiger_test_w3a-d`, off `test/
lines** (2863/14397) — the tested spine is ~100%, the drag is untested feature modules (blog/seo/media/
profile/analytics/backup/identity/schedule at 0%) + the marketplace/install cluster. **Target: 90%.**
-### Next waves (priority order per §5/§8) — the drive to 90%
-- **Wave 4 — breadth over the zero-coverage core modules** (now unblocked by the savepoint mode): each is
- small — media, profile, identity, blog, seo, analytics, backup, schedule, ally, search, agent — parallel
- agents, one module (or a cluster) each, service + model + controller coverage. Biggest, fastest % gain.
-- **Wave 5 — finish the kernel install/marketplace cluster** (`Module_Installer`, `Vendor`, `Update_Checker`,
- `Module_Github`, `Module_Dependency`) — the largest untested library chunk, security-adjacent.
-- **Wave 6 — satellite repos:** stand up a harness in each, then TigerShield WAF engines first.
+### Wave 4 — module breadth + install cluster (LANDED 2026-07-24) → coverage 20.5% → 37.9%
+6 parallel agents (own worktree + DB each), **+316 integration tests** (265 → 581 green) + install-cluster unit
+tests. Per-bucket line-%: **ally 100 · seo 88 · blog 83 · signup 87 · search 73 · access 69 · schedule 62 ·
+profile 56 · identity 45 · analytics 41 · media 34 · backup 31 · system 37**; the kernel install/marketplace
+cluster ~2–8×'d (`Dependency` 97 · `License_Authority` 94 · `Vendor` 80 · `Update_Checker` 79 · `Installer` 68 ·
+`Github` 66) lifting **library/Tiger 25 → 32%**. No product bugs found. CI floor `MIN_COVERAGE` 18 → 35.
+- **Real fix at collection:** `Tiger_Db_Migrator` gained an optional **ledger-table** arg (default
+ `tiger_migration`, prod unchanged); `MigratorTest` now uses an ISOLATED ledger. Root cause: `rollback()`
+ reverses the newest applied version GLOBALLY, and a module's timestamp-versioned migration (committed past
+ the per-test rollback — DDL auto-commits — by the install lifecycle test) sorted above the `9xxx` fixtures →
+ a cross-test flake. Hermetic ledger kills it.
+- **Recurring ceiling (not bugs):** `is_uploaded_file()` gates upload happy-paths (profile Avatar/OrgLogo,
+ media upload) → unreachable from CLI; render-only controllers + `exit;`-ending file/serve actions need a
+ full MVC/view boot the harness skips. These are functional/HTTP-test concerns — the % gaps below ~90 on
+ media/profile/backup are mostly these, not missing logic.
+
+### Next waves (priority order) — the drive to 90%
+- **Wave 5 — the library/Tiger KERNEL** (9,176 lines @ 32% = the dominant remaining gap, ~6,200 uncovered):
+ the CMS engine (`Cms_*`, `Menu`, `Page*` finders), `Mail`, `Log`, `Location` adapters, `Admin_*` registries,
+ `Form`/`Validate`/`Form_Element_*`, `Ajax_ServiceFactory` remainder, and the untested branches of the Model
+ + Service classes. This is what actually moves the overall number to the 80s. Parallel agents by sub-package.
+- **Wave 6 — the remaining modules:** `agent` (462 @ 5% — needs a provider-adapter stub), `cms` module (510 @
+ 33%), `code` (238 @ 41%), and `core/controllers` (360 @ 0% — needs the controller-dispatch harness).
+- **Wave 7 — satellite repos:** stand up a harness in each, then TigerShield WAF engines first.
---
*Manifest generated 2026-07-18 by 6 parallel read-only scans; graduated into `tests/COVERAGE-PLAN.md` +
diff --git a/tests/Integration/Ally/BootstrapTest.php b/tests/Integration/Ally/BootstrapTest.php
new file mode 100644
index 0000000..c145507
--- /dev/null
+++ b/tests/Integration/Ally/BootstrapTest.php
@@ -0,0 +1,66 @@
+setAccessible(true);
+ $items = [];
+ foreach ((array) $p->getValue() as $item) {
+ $items[$item['key'] ?? ''] = $item;
+ }
+ return $items;
+ }
+
+ #[Test]
+ public function it_registers_the_accessibility_sidebar_item(): void
+ {
+ Tiger_Admin_Nav::clear();
+
+ $bootstrap = (new \ReflectionClass(Ally_Bootstrap::class))->newInstanceWithoutConstructor();
+ $m = new ReflectionMethod(Ally_Bootstrap::class, '_initAdminNav');
+ $m->setAccessible(true);
+ $m->invoke($bootstrap);
+
+ $items = $this->registered();
+ $this->assertArrayHasKey('ally', $items, 'the Accessibility nav item is registered');
+ $this->assertSame('Accessibility', $items['ally']['label']);
+ $this->assertSame('/ally', $items['ally']['href']);
+ $this->assertSame('Ally_IndexController', $items['ally']['resource'], 'ACL-gated to the controller');
+ }
+}
diff --git a/tests/Integration/Ally/IndexControllerTest.php b/tests/Integration/Ally/IndexControllerTest.php
new file mode 100644
index 0000000..d16b691
--- /dev/null
+++ b/tests/Integration/Ally/IndexControllerTest.php
@@ -0,0 +1,73 @@
+priorUnitTestMode = Zend_Session::$_unitTestEnabled;
+ Zend_Session::$_unitTestEnabled = true; // array-backed session so FlashMessenger runs under CLI
+ $_SESSION = [];
+
+ Zend_Registry::set('Zend_View', new Zend_View());
+ Zend_Controller_Front::getInstance()->addControllerDirectory(TIGER_CORE_PATH . '/modules/ally/controllers', 'ally');
+ Zend_Layout::startMvc();
+ }
+
+ protected function tearDown(): void
+ {
+ Zend_Layout::resetMvcInstance();
+ Zend_Controller_Front::getInstance()->resetInstance();
+ $_SESSION = [];
+ Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode;
+ $reg = Zend_Registry::getInstance();
+ if ($reg->offsetExists('Zend_View')) { $reg->offsetUnset('Zend_View'); }
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function index_action_sets_the_page_title(): void
+ {
+ $req = new Zend_Controller_Request_Http();
+ $req->setModuleName('ally')->setControllerName('index')->setActionName('index');
+ $res = new Zend_Controller_Response_Http();
+
+ $ctrl = new Ally_IndexController($req, $res);
+ // Touch the ViewRenderer so it initializes the controller's view (a real dispatch does this
+ // before the action runs); indexAction seeds $this->view->title.
+ $ctrl->getHelper('viewRenderer');
+ $ctrl->indexAction();
+
+ $this->assertStringContainsString('Accessibility', (string) $ctrl->view->title);
+ // init() set the admin layout for the shell.
+ $this->assertSame('admin', $ctrl->getHelper('layout')->getLayout());
+ }
+}
diff --git a/tests/Integration/Ally/ScanServiceTest.php b/tests/Integration/Ally/ScanServiceTest.php
new file mode 100644
index 0000000..73eb71a
--- /dev/null
+++ b/tests/Integration/Ally/ScanServiceTest.php
@@ -0,0 +1,259 @@
+ $action] + $params))->getResponse();
+ }
+
+ /** Seed a CMS page (html format) directly; returns its id. Stays inside the harness txn. */
+ private function seedPage(array $overrides): string
+ {
+ return (new Tiger_Model_Page())->insert(array_merge([
+ 'org_id' => '',
+ 'type' => Tiger_Model_Page::TYPE_PAGE,
+ 'locale' => 'en',
+ 'status' => Tiger_Model_Page::STATUS_PUBLISHED,
+ 'title' => 'Seed',
+ 'slug' => 'seed',
+ 'format' => Tiger_Model_Page::FORMAT_HTML,
+ 'body' => '
ok
',
+ 'meta' => '{}',
+ ], $overrides));
+ }
+
+ // ----- ACL gate -----------------------------------------------------------------------------
+
+ #[Test]
+ public function guest_and_plain_user_are_denied_every_action(): void
+ {
+ foreach (['scan', 'pages', 'scanAll', 'scanModule'] as $action) {
+ $this->login('anon', 'org-test', 'guest');
+ $res = $this->call($action, ['html' => 'x
', 'module' => 'ally']);
+ $this->assertSame(0, (int) $res->result, "guest denied on $action");
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages), "ACL denial on $action");
+
+ $this->loginAs('user');
+ $res = $this->call($action, ['html' => 'x
', 'module' => 'ally']);
+ $this->assertSame(0, (int) $res->result, "plain user denied on $action");
+ }
+ }
+
+ // ----- scan (pasted HTML) -------------------------------------------------------------------
+
+ #[Test]
+ public function scan_reports_an_image_without_alt_from_pasted_html(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scan', ['html' => '']);
+
+ $this->assertSame(1, (int) $res->result);
+ $this->assertFalse($res->data['passed'], 'a missing alt is an error → not passed');
+ $this->assertGreaterThanOrEqual(1, $res->data['summary']['error']);
+ $rules = array_column($res->data['findings'], 'rule');
+ $this->assertContains('img-alt', $rules);
+ $this->assertNull($res->data['source'], 'pasted HTML has no page source');
+ }
+
+ #[Test]
+ public function clean_html_passes(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scan', ['html' => 'Just text, nothing to flag.
']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertTrue($res->data['passed']);
+ $this->assertSame(0, $res->data['summary']['error']);
+ }
+
+ #[Test]
+ public function scan_rejects_empty_html(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scan', ['html' => ' ']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('ally.scan.empty', json_encode($res->messages));
+ }
+
+ // ----- scan (a CMS page by id) --------------------------------------------------------------
+
+ #[Test]
+ public function scan_renders_a_cms_page_by_id_and_attaches_the_source(): void
+ {
+ $this->loginAs('admin');
+ $id = $this->seedPage(['title' => 'Landing', 'slug' => 'landing', 'body' => '
']);
+
+ $res = $this->call('scan', ['page_id' => $id]);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertIsArray($res->data['source']);
+ $this->assertSame($id, $res->data['source']['page_id']);
+ $this->assertSame('Landing', $res->data['source']['title']);
+ $rules = array_column($res->data['findings'], 'rule');
+ $this->assertContains('img-alt', $rules, 'the rendered page body is inspected');
+ }
+
+ #[Test]
+ public function scan_errors_on_an_unknown_page_id(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scan', ['page_id' => 'does-not-exist']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('ally.scan.page_not_found', json_encode($res->messages));
+ }
+
+ // ----- pages (the picker list) --------------------------------------------------------------
+
+ #[Test]
+ public function pages_lists_the_scannable_cms_pages(): void
+ {
+ $this->loginAs('admin');
+ $this->seedPage(['title' => 'Alpha', 'slug' => 'alpha']);
+ $this->seedPage(['title' => 'Beta', 'slug' => 'beta']);
+
+ $res = $this->call('pages');
+ $this->assertSame(1, (int) $res->result);
+ $slugs = array_column($res->data['pages'], 'slug');
+ $this->assertContains('alpha', $slugs);
+ $this->assertContains('beta', $slugs);
+ }
+
+ // ----- scanAll (per-page roll-up) -----------------------------------------------------------
+
+ #[Test]
+ public function scan_all_rolls_up_pass_fail_across_pages(): void
+ {
+ $this->loginAs('admin');
+ $this->seedPage(['title' => 'Good', 'slug' => 'good', 'body' => 'fine
']);
+ $this->seedPage(['title' => 'Bad', 'slug' => 'bad', 'body' => '
']);
+
+ $res = $this->call('scanAll');
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame(2, $res->data['totals']['pages']);
+ $this->assertGreaterThanOrEqual(1, $res->data['totals']['passed'], 'the clean page passes');
+ $this->assertGreaterThanOrEqual(1, $res->data['totals']['error'], 'the img-alt page contributes an error');
+ $this->assertCount(2, $res->data['results']);
+ }
+
+ // ----- scanModule (view-template scan) ------------------------------------------------------
+
+ #[Test]
+ public function scan_module_scans_a_modules_view_templates(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scanModule', ['module' => 'ally']);
+
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('ally', $res->data['module']);
+ $this->assertGreaterThanOrEqual(1, $res->data['totals']['scanned'], 'at least the index.phtml was scanned');
+ $this->assertArrayHasKey('files', $res->data);
+ }
+
+ #[Test]
+ public function scan_module_attributes_findings_to_files_when_a_view_has_gaps(): void
+ {
+ // The blog module ships view templates with nominal a11y gaps — a good fixture for the
+ // per-file attribution path (the files[] roll-up branch).
+ $this->loginAs('admin');
+ $res = $this->call('scanModule', ['module' => 'blog']);
+
+ $this->assertSame(1, (int) $res->result);
+ $this->assertGreaterThanOrEqual(1, $res->data['totals']['files_with_issues']);
+ $this->assertNotEmpty($res->data['files'], 'offending files are listed');
+ $first = $res->data['files'][0];
+ $this->assertStringStartsWith('application/modules/blog/views', $first['file'], 'finding attributed to its source file');
+ $this->assertArrayHasKey('findings', $first);
+ }
+
+ // ----- render-failure branches --------------------------------------------------------------
+
+ #[Test]
+ public function scan_reports_a_render_failure_for_a_broken_page(): void
+ {
+ $this->loginAs('admin');
+ // A phtml page whose body throws at render time → the service's render_failed guard.
+ $id = $this->seedPage([
+ 'title' => 'Broken',
+ 'slug' => 'broken',
+ 'format' => Tiger_Model_Page::FORMAT_PHTML,
+ 'body' => '',
+ ]);
+ // The service logs the render failure to a stream sink (php://stdout, which bypasses PHP output
+ // buffering) — swap Tiger_Log's logger for a null writer so the diagnostic line doesn't count as
+ // unexpected test output under strict mode. Restored after.
+ $logProp = new \ReflectionProperty(\Tiger_Log::class, '_log');
+ $logProp->setAccessible(true);
+ $prior = $logProp->getValue();
+ $logProp->setValue(null, (new \Zend_Log())->addWriter(new \Zend_Log_Writer_Null()));
+ try {
+ $res = $this->call('scan', ['page_id' => $id]);
+ } finally {
+ $logProp->setValue(null, $prior);
+ }
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('ally.scan.render_failed', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function scan_all_marks_a_page_that_fails_to_render_as_skipped(): void
+ {
+ $this->loginAs('admin');
+ $this->seedPage(['title' => 'OK', 'slug' => 'ok-page', 'body' => 'fine
']);
+ $this->seedPage([
+ 'title' => 'Broken',
+ 'slug' => 'broken-page',
+ 'format' => Tiger_Model_Page::FORMAT_PHTML,
+ 'body' => '',
+ ]);
+
+ $res = $this->call('scanAll');
+ $this->assertSame(1, (int) $res->result);
+ $skipped = array_filter($res->data['results'], static fn ($r) => !empty($r['skipped']));
+ $this->assertNotEmpty($skipped, 'the un-renderable page is flagged skipped, not fatal');
+ }
+
+ #[Test]
+ public function scan_module_errors_on_an_unknown_module(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scanModule', ['module' => 'no-such-module-xyz']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('ally.scan.module_not_found', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function scan_module_rejects_a_blank_module(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call('scanModule', ['module' => '']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('ally.scan.module_not_found', json_encode($res->messages));
+ }
+}
diff --git a/tests/Integration/Analytics/AnalyticsServiceTest.php b/tests/Integration/Analytics/AnalyticsServiceTest.php
new file mode 100644
index 0000000..2d5302d
--- /dev/null
+++ b/tests/Integration/Analytics/AnalyticsServiceTest.php
@@ -0,0 +1,139 @@
+offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); }
+ parent::tearDown();
+ }
+
+ private function call(array $params = []): object
+ {
+ return (new Analytics_Service_Analytics(['action' => 'save'] + $params))->getResponse();
+ }
+
+ private function cfg(string $key): ?string
+ {
+ return (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', $key);
+ }
+
+ // ----- ACL ----------------------------------------------------------------------------------
+
+ #[Test]
+ public function guest_is_denied(): void
+ {
+ $this->login('anon', 'org-test', 'guest');
+ $res = $this->call(['ga4_measurement_id' => 'G-ABC123', 'enabled' => '1']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function a_plain_user_is_denied(): void
+ {
+ $this->loginAs('user');
+ $res = $this->call(['ga4_measurement_id' => 'G-ABC123']);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages));
+ }
+
+ // ----- save ---------------------------------------------------------------------------------
+
+ #[Test]
+ public function save_persists_the_measurement_id_and_switches(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call([
+ 'ga4_measurement_id' => 'G-ABC123XYZ',
+ 'enabled' => '1',
+ 'exclude_signed_in' => '1',
+ ]);
+
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('1', $this->cfg('tiger.analytics.enabled'));
+ $this->assertSame('G-ABC123XYZ', $this->cfg('tiger.analytics.ga4.measurement_id'));
+ $this->assertSame('1', $this->cfg('tiger.analytics.exclude_signed_in'));
+ // The reporting bridge (Tiger_Google_Analytics) writes the connect mode too.
+ $this->assertNotNull($this->cfg('tiger.analytics.oauth.mode'));
+ }
+
+ #[Test]
+ public function save_records_disabled_and_included_when_switches_are_off(): void
+ {
+ $this->loginAs('admin');
+ // No `enabled`/`exclude_signed_in` keys → both persist as '0'.
+ $res = $this->call(['ga4_measurement_id' => 'G-ONLYID9']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('0', $this->cfg('tiger.analytics.enabled'));
+ $this->assertSame('0', $this->cfg('tiger.analytics.exclude_signed_in'));
+ }
+
+ #[Test]
+ public function save_accepts_an_empty_measurement_id(): void
+ {
+ // The id is optional (a site may enable later); an empty value validates and stores ''.
+ $this->loginAs('admin');
+ $res = $this->call(['ga4_measurement_id' => '', 'enabled' => '0']);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('', $this->cfg('tiger.analytics.ga4.measurement_id'));
+ }
+
+ #[Test]
+ public function save_persists_the_byo_oauth_connect_fields(): void
+ {
+ $this->loginAs('admin');
+ $res = $this->call([
+ 'ga4_measurement_id' => 'G-CONNECT1',
+ 'enabled' => '1',
+ 'oauth_mode' => 'byo',
+ 'oauth_client_id' => 'client-abc.apps.googleusercontent.com',
+ 'property_id' => '123456789',
+ ]);
+ $this->assertSame(1, (int) $res->result);
+ $this->assertSame('byo', $this->cfg('tiger.analytics.oauth.mode'));
+ $this->assertSame('client-abc.apps.googleusercontent.com', $this->cfg('tiger.analytics.oauth.client_id'));
+ $this->assertSame('123456789', $this->cfg('tiger.analytics.property_id'), 'digits-only property id stored');
+ }
+
+ #[Test]
+ public function an_invalid_measurement_id_returns_form_errors_and_writes_nothing(): void
+ {
+ $this->loginAs('admin');
+ $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM config');
+
+ // 'UA-12345-6' is a Universal Analytics id, not a GA4 `G-XXXX` — the regex rejects it.
+ $res = $this->call(['ga4_measurement_id' => 'UA-12345-6', 'enabled' => '1']);
+
+ $this->assertSame(0, (int) $res->result);
+ $this->assertNotNull($res->form);
+ $this->assertArrayHasKey('ga4_measurement_id', $res->form);
+ $this->assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM config'), 'nothing written on a reject');
+ }
+}
diff --git a/tests/Integration/Analytics/ReportsServiceTest.php b/tests/Integration/Analytics/ReportsServiceTest.php
new file mode 100644
index 0000000..7f0d66e
--- /dev/null
+++ b/tests/Integration/Analytics/ReportsServiceTest.php
@@ -0,0 +1,123 @@
+cacheFile !== null && is_file($this->cacheFile)) { @unlink($this->cacheFile); }
+ parent::tearDown();
+ }
+
+ private function call(string $action, array $params = []): object
+ {
+ return (new Analytics_Service_Reports(['action' => $action] + $params))->getResponse();
+ }
+
+ #[Test]
+ public function guest_is_denied_on_summary_and_test(): void
+ {
+ $this->login('anon', 'org-test', 'guest');
+ foreach (['summary', 'test'] as $action) {
+ $res = $this->call($action);
+ $this->assertSame(0, (int) $res->result, "guest denied on {$action}");
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages));
+ }
+ }
+
+ #[Test]
+ public function a_plain_user_is_denied(): void
+ {
+ $this->loginAs('user');
+ $res = $this->call('summary');
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('not_allowed', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function summary_reports_not_connected_when_ga_is_unconfigured(): void
+ {
+ // No GA config anywhere → Tiger_Google_Analytics::isConnected() is false → the guard fires.
+ $this->loginAs('admin');
+ $res = $this->call('summary', ['days' => 28]);
+ $this->assertSame(0, (int) $res->result);
+ $this->assertStringContainsString('analytics.reports.not_connected', json_encode($res->messages));
+ }
+
+ #[Test]
+ public function test_returns_a_not_connected_diagnosis_as_a_successful_call(): void
+ {
+ // The self-test is a successful CALL even when the connection is down — the client reads
+ // data.test.ok to render the message/hint. Unconfigured → ok=false, code=not_connected.
+ $this->loginAs('admin');
+ $res = $this->call('test');
+ $this->assertSame(1, (int) $res->result, 'the call itself succeeds');
+ $this->assertIsArray($res->data['test']);
+ $this->assertFalse($res->data['test']['ok']);
+ $this->assertSame('not_connected', $res->data['test']['code']);
+ }
+
+ #[Test]
+ public function summary_returns_the_cached_ga_data_when_connected(): void
+ {
+ // Connect GA (crypto key + property + decryptable refresh token) and pre-seed the report cache
+ // so summary() serves it WITHOUT any network round-trip to Google — covers the connected path.
+ $this->connectGa();
+ $expected = ['range' => ['days' => 28], 'totals' => [10, 5, 42], 'series' => [['users' => 3]]];
+ $this->seedCache(28, $expected);
+
+ $this->loginAs('admin');
+ $res = $this->call('summary', ['days' => 28]);
+
+ $this->assertSame(1, (int) $res->result, 'connected + cached → success');
+ $this->assertSame($expected, $res->data['summary'], 'the cached summary is returned verbatim');
+ }
+
+ /** Make Tiger_Google_Analytics::isConnected() true (broker mode: crypto key + property + refresh token). */
+ private function connectGa(): void
+ {
+ $key = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; // 32 zero bytes, base64
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['crypto' => ['key' => $key]]], true));
+ $enc = Tiger_Crypto::encrypt('fake-refresh-token');
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => [
+ 'crypto' => ['key' => $key],
+ 'analytics' => [
+ 'property_id' => self::PROPERTY_ID,
+ 'oauth' => ['mode' => 'broker', 'refresh_token_enc' => $enc],
+ ],
+ ]], true));
+ }
+
+ /** Write a fresh report-cache file at the path Tiger_Google_Analytics::_cacheFile() computes. */
+ private function seedCache(int $days, array $data): void
+ {
+ $base = (defined('APPLICATION_PATH') ? dirname(APPLICATION_PATH) : sys_get_temp_dir()) . '/var/cache/analytics';
+ if (!is_dir($base)) { @mkdir($base, 0775, true); }
+ $this->cacheFile = $base . '/ga-' . substr(md5(self::PROPERTY_ID), 0, 10) . '-' . $days . 'd.json';
+ file_put_contents($this->cacheFile, json_encode($data));
+ }
+}
diff --git a/tests/Integration/Analytics/TagPluginTest.php b/tests/Integration/Analytics/TagPluginTest.php
new file mode 100644
index 0000000..9544cee
--- /dev/null
+++ b/tests/Integration/Analytics/TagPluginTest.php
@@ -0,0 +1,146 @@
+view = new Zend_View();
+ Zend_Registry::set('Zend_View', $this->view);
+ // Placeholder containers are a process-wide singleton — clear the one this plugin writes so
+ // an emission from a prior test can't bleed into this one.
+ $this->view->placeholder('tigerTracking')->exchangeArray([]);
+ $this->resetLatch();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->resetLatch();
+ parent::tearDown();
+ }
+
+ /** Reset the plugin's emit-once static so each test starts clean. */
+ private function resetLatch(): void
+ {
+ $p = new ReflectionProperty(Analytics_Plugin_Tag::class, '_done');
+ $p->setValue(null, false);
+ }
+
+ private function config(array $analytics): void
+ {
+ Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['analytics' => $analytics]], true));
+ }
+
+ private function tracking(): string
+ {
+ return (string) $this->view->placeholder('tigerTracking');
+ }
+
+ // ----- measurementId / isConfigured ---------------------------------------------------------
+
+ #[Test]
+ public function measurement_id_is_empty_when_disabled(): void
+ {
+ $this->config(['enabled' => '0', 'ga4' => ['measurement_id' => 'G-VALID123']]);
+ $this->assertSame('', Analytics_Plugin_Tag::measurementId(), 'disabled → no id, even with one set');
+ $this->assertFalse(Analytics_Plugin_Tag::isConfigured());
+ }
+
+ #[Test]
+ public function measurement_id_is_empty_for_an_invalid_id(): void
+ {
+ $this->config(['enabled' => '1', 'ga4' => ['measurement_id' => 'not-a-ga4-id']]);
+ $this->assertSame('', Analytics_Plugin_Tag::measurementId(), 'a non G-XXXX id is rejected');
+ $this->assertFalse(Analytics_Plugin_Tag::isConfigured());
+ }
+
+ #[Test]
+ public function measurement_id_returns_a_valid_enabled_id(): void
+ {
+ $this->config(['enabled' => '1', 'ga4' => ['measurement_id' => 'G-VALID123']]);
+ $this->assertSame('G-VALID123', Analytics_Plugin_Tag::measurementId());
+ $this->assertTrue(Analytics_Plugin_Tag::isConfigured());
+ }
+
+ // ----- preDispatch emission -----------------------------------------------------------------
+
+ #[Test]
+ public function predispatch_appends_the_snippet_when_configured_for_a_guest(): void
+ {
+ // Guest (no identity) → not staff; consent allows by default; enabled + valid id → emit.
+ $this->config(['enabled' => '1', 'ga4' => ['measurement_id' => 'G-EMIT4567']]);
+
+ (new Analytics_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple());
+
+ $out = $this->tracking();
+ $this->assertStringContainsString('googletagmanager.com/gtag/js?id=G-EMIT4567', $out, 'gtag script emitted');
+ $this->assertStringContainsString("gtag('config','G-EMIT4567')", $out, 'config call emitted');
+ }
+
+ #[Test]
+ public function predispatch_emits_nothing_when_unconfigured(): void
+ {
+ $this->config(['enabled' => '0', 'ga4' => ['measurement_id' => 'G-EMIT4567']]);
+ (new Analytics_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple());
+ $this->assertSame('', $this->tracking(), 'disabled → nothing in the placeholder');
+ }
+
+ #[Test]
+ public function predispatch_skips_signed_in_staff_when_exclusion_is_on(): void
+ {
+ // exclude_signed_in on (default) + a signed-in admin (staff) → the tag is withheld.
+ $this->config(['enabled' => '1', 'exclude_signed_in' => '1', 'ga4' => ['measurement_id' => 'G-STAFF999']]);
+ $this->loginAs('admin');
+
+ (new Analytics_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple());
+ $this->assertSame('', $this->tracking(), 'staff traffic is not tagged');
+ }
+
+ #[Test]
+ public function predispatch_tags_signed_in_staff_when_exclusion_is_off(): void
+ {
+ // With exclusion turned off, even a signed-in admin gets tagged.
+ $this->config(['enabled' => '1', 'exclude_signed_in' => '0', 'ga4' => ['measurement_id' => 'G-ALLSTAFF']]);
+ $this->loginAs('admin');
+
+ (new Analytics_Plugin_Tag())->preDispatch(new Zend_Controller_Request_Simple());
+ $this->assertStringContainsString('G-ALLSTAFF', $this->tracking(), 'exclusion off → staff tagged');
+ }
+
+ #[Test]
+ public function the_emit_once_latch_prevents_a_second_append(): void
+ {
+ $this->config(['enabled' => '1', 'ga4' => ['measurement_id' => 'G-ONCE1234']]);
+ $plugin = new Analytics_Plugin_Tag();
+ $plugin->preDispatch(new Zend_Controller_Request_Simple());
+ $plugin->preDispatch(new Zend_Controller_Request_Simple()); // second dispatch/forward
+ $this->assertSame(1, substr_count($this->tracking(), 'gtag/js?id='), 'the snippet is appended exactly once');
+ }
+}
diff --git a/tests/Integration/Analytics/WidgetTest.php b/tests/Integration/Analytics/WidgetTest.php
new file mode 100644
index 0000000..1df7ac1
--- /dev/null
+++ b/tests/Integration/Analytics/WidgetTest.php
@@ -0,0 +1,71 @@
+render();
+
+ $this->assertStringContainsString('Connect Google Analytics', $html, 'the set-up prompt is shown');
+ $this->assertStringContainsString('/analytics/admin', $html, 'links to the settings screen');
+ $this->assertStringContainsString('btn', $html, 'renders a CTA button');
+ $this->assertStringNotContainsString('