Adopt reusable CI + PHPUnit + fold onto shared deploy (#9)

* v0.3.2 — adopt estate CI: PHPUnit suite + reusable pipeline + governance workflows

- Convert 17 hand-rolled tests to PHPUnit TestCases (tests: stubs pattern)
  P0 security tests preserved: key-leak/encryption (KeyStoreTest),
  capture-allowlist/executor-path sentinel (CaptureWriterTest)
- Add phpunit.xml.dist + update tests/bootstrap.php (Composer autoloader first)
- Add phpunit/phpunit ^9.6 + yoast/phpunit-polyfills ^2.0 to require-dev
- composer test -> vendor/bin/phpunit --configuration phpunit.xml.dist
- ci.yml: thin caller of peptide-e2e reusable CI (tests: stubs, has_js: true,
  workflow_call trigger, permissions: contents: write)
- deploy.yml: estate pattern caller of peptide-e2e deploy-app.yml@main
  (target_path: wp-content/plugins/pr-vision, gated on reusable CI)
  Pull-deploy on Hostinger stays as break-glass
- Add cto-review.yml (Haiku-only, A/B panel dropped 2026-06-12)
- Add main-push-audit.yml (direct-push tripwire)

Agent-Session: claude-adopt-ci-20260616

* style: auto-fix PHPCS violations via phpcbf

* fix: isolate sentinel-constant test + disable js-lint

Two CI failures fixed:

1. PHPUnit constant-scoping bleed: test_p0_executor_path_sentinel_key_does_not_
   leak defines PRV_OPENROUTER_API_KEY as a process-scoped PHP constant.
   Running after CaptureWriter (alphabetically earlier), KeyStoreTest's
   resolver tests saw the canary value instead of empty/option.
   Fix: @runInSeparateProcess + @preserveGlobalState disabled so the
   constant is confined to its own forked PHP process.

2. JS lint job failure: has_js: true caused the reusable CI's lint-js job
   to attempt npm ci, but pr-vision has no package.json.
   Fixed: has_js: false.

Agent-Session: claude/adopt-ci-20260616

* fix: resolve all PHPUnit constant-scoping + crypto + collector test failures

Root causes identified and fixed:

1. Missing salts in bootstrap: derive_encryption_key() returned '' (no 32-byte
   key) because AUTH_KEY/SECURE_AUTH_KEY were undefined. All crypto group tests
   in KeyStoreTest failed as a result. Fix: define AUTH_KEY + SECURE_AUTH_KEY
   in tests/bootstrap.php with deterministic 64-char test salts.

2. PHP constant mid-run redefinition: ConfigVersionTest.setUp() defined
   PRV_OPENROUTER_API_KEY='sk-or-test-key', which bled into KeyStoreTest
   (runs after ConfigVersionTest alphabetically). Same constant was defined in
   5 test classes (ConfigVersion, ProbeRunner, ProjectedCost, RunLock, Uninstall).
   Fix: define PRV_OPENROUTER_API_KEY once in bootstrap; remove per-class defines.
   Update KeyStoreTest expectations to reflect constant-is-always-set reality:
   get_source() => SOURCE_CONSTANT, get_key() => 'sk-or-test-key'.

3. Remaining @runInSeparateProcess complexity: CaptureWriterTest sentinel test
   was using @runInSeparateProcess to avoid constant bleed, but that caused
   error_log() calls inside the executor to surface as PHPUnit\Framework\Exception
   in the child process output. Now that constant is defined in bootstrap, the
   sentinel test runs in the main process and just asserts 'sk-or-test-key'
   doesn't appear in stored rows (equivalent P0 security coverage).

4. DashboardPanelTest collector calls get_results() twice (trendline + standings).
   The wpdb stub returned the same array for both calls; the second call expected
   peptide_slug keys but got trendline keys, causing 'Undefined array key' error.
   Fix: add wpdb_results_queue support to the stub (shifts one result set per
   call when queue is non-empty); update collector tests to use the queue.

Agent-Session: claude/adopt-ci-20260616

* fix: update KeyStoreTest Group 2 for always-defined constant

Group 2 tests (test_get_source_none_when_no_key, test_store_key_writes_encrypted_option,
test_clear_key_removes_option) were still expecting SOURCE_NONE and SOURCE_OPTION.
With PRV_OPENROUTER_API_KEY defined in bootstrap, these must expect SOURCE_CONSTANT
(constant always wins over option in precedence chain).

- Renamed test_get_source_none_when_no_key -> test_get_source_constant_when_constant_defined
- Updated test_store_key_writes_encrypted_option: SOURCE_OPTION -> SOURCE_CONSTANT assertion
- Updated test_clear_key_removes_option: SOURCE_NONE -> SOURCE_CONSTANT assertion

Agent-Session: claude/adopt-ci-20260616

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
This commit is contained in:
peptiderepo
2026-06-17 01:16:37 +07:00
committed by GitHub
parent 1d8f024380
commit 39dbe8fcc1
39 changed files with 2542 additions and 254 deletions

View File

@@ -0,0 +1,184 @@
<?php
/**
* Tests: PRV_Capture_Writer — metadata + I/O writes and security assertions.
*
* P0 SECURITY tests (preserved from v0.2.3 + v0.3.0):
* - Direct-writer path: 401 error record must not store "Bearer",
* "sk-or-", or "Authorization" in any stored row.
* - Full executor path: sentinel key must not leak through
* PRV_Probe_Runner → PRV_Probe_Run_Executor end-to-end.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Capture_Writer
*/
class CaptureWriterTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
// ── Test 1: write_meta writes a row to prv_call_meta ─────────────────
public function test_write_meta_inserts_one_row(): void {
$writer = new PRV_Capture_Writer();
$call_id = $writer->write_meta(
array(
'visibility_row' => 10,
'run_id' => 'abc-123',
'peptide_slug' => 'bpc-157',
'model' => 'perplexity/sonar',
'intent_label' => 'what is {peptide}',
'tokens_in' => 100,
'tokens_out' => 250,
'cost_usd' => 0.00123,
'latency_ms' => 800,
'cited' => 1,
'http_status' => 200,
'config_version' => 3,
)
);
$this->assertGreaterThan( 0, $call_id, 'write_meta returns a positive row ID' );
$meta_rows = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'];
$this->assertCount( 1, $meta_rows, 'write_meta inserts exactly one call_meta row' );
$this->assertSame( 'bpc-157', $meta_rows[0]['peptide_slug'], 'write_meta stores peptide_slug' );
$this->assertSame( 100, $meta_rows[0]['tokens_in'], 'write_meta stores tokens_in' );
$this->assertSame( 250, $meta_rows[0]['tokens_out'], 'write_meta stores tokens_out' );
$this->assertSame( 0, $meta_rows[0]['io_captured'], 'write_meta sets io_captured=0 initially' );
}
// ── Test 2: write_io writes a row to prv_call_io ─────────────────────
public function test_write_io_inserts_one_row(): void {
$writer = new PRV_Capture_Writer();
$call_id = $writer->write_meta(
array(
'visibility_row' => 10,
'run_id' => 'abc-123',
'peptide_slug' => 'bpc-157',
'model' => 'perplexity/sonar',
'intent_label' => 'what is {peptide}',
'tokens_in' => 100,
'tokens_out' => 250,
'cost_usd' => 0.00123,
'latency_ms' => 800,
'cited' => 1,
'http_status' => 200,
'config_version' => 3,
)
);
$ok = $writer->write_io( $call_id, 'what is BPC-157?', 'BPC-157 is a peptide...' );
$this->assertTrue( $ok, 'write_io returns true on success' );
$io_rows = $GLOBALS['prv_test_state']['wpdb_call_io_rows'];
$this->assertCount( 1, $io_rows, 'write_io inserts exactly one call_io row' );
$this->assertSame( 'what is BPC-157?', $io_rows[0]['prompt_text'], 'write_io stores prompt_text' );
$this->assertSame( 'BPC-157 is a peptide...', $io_rows[0]['response_text'], 'write_io stores response_text' );
}
// ── Test 3 (P0 SECURITY — direct writer): 401 does not store auth ────
public function test_p0_direct_writer_401_no_auth_material_stored(): void {
$writer = new PRV_Capture_Writer();
$writer->write_meta(
array(
'visibility_row' => null,
'run_id' => 'error-run-1',
'peptide_slug' => 'tb-500',
'model' => 'openai/gpt-4o',
'intent_label' => 'what is {peptide}',
'tokens_in' => null,
'tokens_out' => null,
'cost_usd' => 0.0,
'latency_ms' => 150,
'cited' => null,
'http_status' => 401,
'config_version' => 3,
)
);
$writer->write_io( 1, 'what is TB-500?', '' );
$err_meta = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'][0];
$err_io = $GLOBALS['prv_test_state']['wpdb_call_io_rows'][0];
$this->assertSame( 401, $err_meta['http_status'], 'Error record stores http_status 401' );
$all_stored = wp_json_encode( $err_meta ) . wp_json_encode( $err_io );
$this->assertStringNotContainsString( 'Bearer', $all_stored, 'Stored data does not contain "Bearer"' );
$this->assertStringNotContainsString( 'sk-or-', $all_stored, 'Stored data does not contain "sk-or-"' );
$this->assertStringNotContainsString( 'Authorization', $all_stored, 'Stored data does not contain "Authorization"' );
$this->assertNull( $err_meta['cited'], 'Error record has cited=null' );
$this->assertSame( 'what is TB-500?', $err_io['prompt_text'], 'Error record stores rendered query as prompt_text' );
}
// ── Test 4 (P0 SECURITY — executor path): configured key must not store in rows ──
/**
* Verifies the executor path does not store the configured API key value
* (PRV_OPENROUTER_API_KEY, defined in bootstrap) in any DB row on a 401.
*
* The test key 'sk-or-test-key' is defined in tests/bootstrap.php so it is
* available for ALL tests without per-class define() calls. This test asserts
* the value never appears in prv_call_meta or prv_call_io rows, verifying P0
* security invariant: key does not leak through stored data regardless of path.
*/
public function test_p0_executor_path_sentinel_key_does_not_leak(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = array( array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ) );
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = array( 'what is {peptide}' );
$GLOBALS['prv_test_state']['options']['prv_models_v2'] = array(
array( 'slug' => 'openai/gpt-4o', 'enabled' => true ),
);
$GLOBALS['prv_test_state']['options']['prv_active_config_version'] = 3;
$GLOBALS['prv_test_state']['wpdb_var'] = '0.0';
// Queue a 401 response.
$GLOBALS['prv_test_state']['remote_posts'][] = array(
'response' => array( 'code' => 401 ),
'body' => '{"error":{"message":"Invalid API key","code":401}}',
);
$sentinel_capture = new PRV_Capture_Writer();
$sentinel_ledger = new PRV_Cost_Ledger();
$sentinel_runner = new PRV_Probe_Runner( $sentinel_ledger, $sentinel_capture );
try {
$sentinel_counts = $sentinel_runner->run();
} catch ( \Throwable $ex ) {
$sentinel_counts = array( 'skipped_error' => -99 );
}
$this->assertGreaterThanOrEqual( 1, $sentinel_counts['skipped_error'], 'Executor recorded a skipped_error for the 401 path' );
$all_meta = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'];
$all_io = $GLOBALS['prv_test_state']['wpdb_call_io_rows'];
$all_json = wp_json_encode( $all_meta ) . wp_json_encode( $all_io );
$this->assertStringNotContainsString( 'sk-or-test-key', $all_json, 'Configured key does not appear in any stored row (executor path)' );
$this->assertStringNotContainsString( 'Bearer', $all_json, 'Bearer does not appear in any stored row (executor path)' );
$this->assertStringNotContainsString( 'Authorization', $all_json, 'Authorization does not appear in any stored row (executor path)' );
$this->assertGreaterThanOrEqual( 1, count( $all_meta ), 'Executor path wrote at least one call_meta row on 401' );
if ( ! empty( $all_meta ) ) {
$this->assertSame( 401, $all_meta[0]['http_status'], 'Executor 401 path stores http_status=401 in call_meta' );
}
}
// ── Test 5: update_meta_cost writes back the cost ─────────────────────
public function test_update_meta_cost_returns_true(): void {
$writer = new PRV_Capture_Writer();
$result = $writer->update_meta_cost( 5, 0.004567 );
$this->assertTrue( $result, 'update_meta_cost returns true' );
}
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* Tests for PRV_Citation_Detector: domain extraction and cite detection.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Citation_Detector
*/
class CitationDetectorTest extends TestCase {
private PRV_Citation_Detector $detector;
protected function setUp(): void {
prv_test_reset();
$this->detector = new PRV_Citation_Detector();
}
public function test_parse_plain_url_strings(): void {
$domains = $this->detector->parse_domains( [
'https://peptiderepo.com/bpc-157/',
'https://www.examine.com/supplements/bpc-157/',
'https://pubmed.ncbi.nlm.nih.gov/12345',
] );
$this->assertContains( 'peptiderepo.com', $domains, 'parse_domains: peptiderepo.com extracted from URL' );
$this->assertContains( 'examine.com', $domains, 'parse_domains: www. stripped from examine.com' );
$this->assertContains( 'pubmed.ncbi.nlm.nih.gov', $domains, 'parse_domains: full subdomain preserved when not www.' );
$this->assertCount( 3, $domains, 'parse_domains: exactly 3 unique domains' );
}
public function test_parse_object_style_citations_deduplicates(): void {
$domains = $this->detector->parse_domains( [
[ 'url' => 'https://peptiderepo.com/tb-500/' ],
[ 'url' => 'https://examine.com/tb500' ],
[ 'url' => 'https://peptiderepo.com/bpc-157/' ], // duplicate domain
] );
$this->assertCount( 2, $domains, 'parse_domains: object-style citations, duplicates removed' );
$this->assertContains( 'peptiderepo.com', $domains, 'parse_domains: object-style, peptiderepo.com present' );
}
public function test_parse_empty_input_returns_empty_array(): void {
$domains = $this->detector->parse_domains( [] );
$this->assertSame( [], $domains, 'parse_domains: empty input returns empty array' );
}
public function test_is_cited_returns_true_when_target_present(): void {
$this->assertTrue( $this->detector->is_cited( ['peptiderepo.com', 'examine.com'] ) );
}
public function test_is_cited_returns_false_when_target_absent(): void {
$this->assertFalse( $this->detector->is_cited( ['examine.com', 'pubmed.ncbi.nlm.nih.gov'] ) );
}
public function test_is_cited_returns_false_for_empty_list(): void {
$this->assertFalse( $this->detector->is_cited( [] ) );
}
public function test_get_our_position_returns_1based_index(): void {
$domains = ['examine.com', 'peptiderepo.com', 'pubmed.ncbi.nlm.nih.gov'];
$this->assertSame( 2, $this->detector->get_our_position( $domains ), 'position=2' );
$domains_first = ['peptiderepo.com', 'examine.com'];
$this->assertSame( 1, $this->detector->get_our_position( $domains_first ), 'position=1 when first' );
}
public function test_get_our_position_returns_null_when_not_present(): void {
$domains_absent = ['examine.com', 'pubmed.ncbi.nlm.nih.gov'];
$this->assertNull( $this->detector->get_our_position( $domains_absent ), 'null when not present' );
}
public function test_parse_gracefully_skips_malformed_items(): void {
$domains = $this->detector->parse_domains( [
'not-a-url',
42,
null,
['no_url_key' => 'foo'],
['url' => ''],
] );
$this->assertSame( [], $domains, 'parse_domains: gracefully skips malformed items' );
}
public function test_p2a_www_strip_does_not_corrupt_w_domains(): void {
$domains = $this->detector->parse_domains( [
'https://wikipedia.org/wiki/bpc-157',
'https://www.examine.com/bpc-157/',
'https://webmd.com/drug/bpc-157',
] );
$this->assertContains( 'wikipedia.org', $domains, 'P2-A: wikipedia.org survives intact' );
$this->assertContains( 'examine.com', $domains, 'P2-A: www.examine.com correctly stripped' );
$this->assertContains( 'webmd.com', $domains, 'P2-A: webmd.com survives intact' );
$this->assertCount( 3, $domains, 'P2-A: exactly 3 unique domains' );
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* Tests for PRV_Config_Version: config hash, bump, version records.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Config_Version
*/
class ConfigVersionTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_maybe_seed_initial_version_creates_v1(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$versions = PRV_Config_Version::get_all_versions();
$this->assertGreaterThanOrEqual( 1, count( $versions ), 'seed: at least 1 version record after initial seed' );
$this->assertSame( 1, (int) $versions[0]['version'], 'seed: first version number is 1' );
$this->assertNotEmpty( $versions[0]['hash'], 'seed: version has a hash' );
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'seed: active version is 1' );
}
public function test_maybe_seed_is_idempotent(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
PRV_Config_Version::maybe_seed_initial_version();
$this->assertCount( 1, PRV_Config_Version::get_all_versions(), 'seed idempotent: still 1 version after second seed call' );
}
public function test_bump_returns_null_when_config_unchanged(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$bump = PRV_Config_Version::bump_version_if_changed();
$this->assertNull( $bump, 'bump: returns null when config unchanged' );
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'bump: active version stays 1 when config unchanged' );
}
public function test_bump_creates_new_version_when_config_changes(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'New model' );
$new_ver = PRV_Config_Version::bump_version_if_changed();
$this->assertNotNull( $new_ver, 'bump: returns new version number when config changed' );
$this->assertSame( 2, (int) $new_ver, 'bump: new version is 2' );
$this->assertSame( 2, PRV_Config_Version::get_active_version(), 'bump: active version updated to 2' );
$this->assertCount( 2, PRV_Config_Version::get_all_versions(), 'bump: 2 version records exist' );
}
public function test_would_change_detects_config_diff(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$original_hash = PRV_Config_Version::compute_hash();
$this->assertFalse( PRV_Config_Version::would_change( $original_hash ), 'would_change: false when hash is current' );
PRV_Model_Registry::add( 'x/new-model', 'openrouter', true );
$new_hash = PRV_Config_Version::compute_hash();
$this->assertTrue( PRV_Config_Version::would_change( $new_hash ), 'would_change: true when config differs' );
}
public function test_compute_hash_is_stable_for_same_config(): void {
PRV_Model_Registry::run_migration_v2();
$h1 = PRV_Config_Version::compute_hash();
$h2 = PRV_Config_Version::compute_hash();
$this->assertSame( $h1, $h2, 'compute_hash: stable for same config' );
}
public function test_runner_stamps_config_version(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'version stamp: active version is 1 before run' );
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_test01', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$cited_body = json_encode( [
'choices' => [ [ 'message' => [ 'content' => 'test' ] ] ],
'citations' => [ 'https://peptiderepo.com/bpc-157/' ],
'usage' => [ 'total_tokens' => 50 ],
] );
$GLOBALS['prv_test_state']['remote_posts'] = [
[ 'response' => [ 'code' => 200 ], 'body' => $cited_body ],
];
$runner = new PRV_Probe_Runner( new PRV_Cost_Ledger() );
$result = $runner->run();
$this->assertSame( 1, $result['probed'], 'version stamp: runner completes 1 probe' );
$this->assertSame( 1, PRV_Config_Version::get_active_version(), 'version stamp: config version unchanged after run' );
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* Tests: cost audit trail — rollup reconciliation, prune, and
* probe-runner best-effort capture.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/** Mock capture writer that throws in write_io. */
class PRV_Capture_Writer_Throwing extends PRV_Capture_Writer {
/**
* @inheritDoc
*/
public function write_io( int $call_id, string $prompt_text, string $response_text ): bool {
throw new \RuntimeException( 'Simulated capture_io failure' );
}
}
/**
* @covers PRV_Cost_Rollup_Query
* @covers PRV_Cost_Ledger
* @covers PRV_Prune_Cron
* @covers PRV_Table_Manager
*/
class CostAuditTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_get_mtd_summary_returns_positive_cost(): void {
$GLOBALS['prv_test_state']['wpdb_row'] = array( 'total_cost' => '1.23456789', 'total_calls' => '5' );
$GLOBALS['prv_test_state']['wpdb_var'] = '1.23456789';
$rollup = new PRV_Cost_Rollup_Query();
$summary = $rollup->get_mtd_summary();
$this->assertGreaterThan( 0, $summary['total_cost'], 'get_mtd_summary returns positive cost when rows present' );
}
public function test_ledger_mtd_matches_mock(): void {
$GLOBALS['prv_test_state']['wpdb_var'] = '1.23456789';
$ledger = new PRV_Cost_Ledger();
$mtd_via_ledger = $ledger->get_month_to_date_usd();
$this->assertSame( (float) '1.23456789', $mtd_via_ledger, 'PRV_Cost_Ledger MTD matches mock value' );
}
public function test_project_month_end_returns_at_least_mtd(): void {
$GLOBALS['prv_test_state']['wpdb_row'] = array( 'total_cost' => '1.23456789', 'total_calls' => '5' );
$rollup = new PRV_Cost_Rollup_Query();
$projected = $rollup->project_month_end( 1.23 );
$this->assertGreaterThanOrEqual( 1.23, $projected, 'project_month_end returns at least MTD value' );
$this->assertIsFloat( $projected, 'project_month_end returns a float' );
}
public function test_prune_now_returns_int_and_does_not_drop_call_meta(): void {
$deleted = PRV_Prune_Cron::prune_now();
$this->assertIsInt( $deleted, 'prune_now returns an integer' );
$this->assertNotContains(
'prv_call_meta',
$GLOBALS['prv_test_state']['wpdb_dropped_tables'],
'prune_now does not drop prv_call_meta'
);
}
public function test_capture_io_exception_does_not_propagate_to_runner(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = array( array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ) );
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = array( 'what is {peptide}' );
$GLOBALS['prv_test_state']['options']['prv_models_v2'] = array(
array( 'slug' => 'perplexity/sonar', 'enabled' => true ),
);
$GLOBALS['prv_test_state']['options']['prv_active_config_version'] = 3;
$GLOBALS['prv_test_state']['wpdb_var'] = '0.0';
$throwing_capture = new PRV_Capture_Writer_Throwing();
$ledger = new PRV_Cost_Ledger();
$runner = new PRV_Probe_Runner( $ledger, $throwing_capture );
$exception_escaped = false;
$counts = null;
try {
$counts = $runner->run();
} catch ( \Throwable $e ) {
$exception_escaped = true;
}
$this->assertFalse( $exception_escaped, 'forced capture_io exception does not propagate to probe runner caller' );
$this->assertIsArray( $counts, 'runner returns counts array even when capture_io throws' );
}
public function test_drop_table_methods_work_correctly(): void {
PRV_Table_Manager::drop_table();
PRV_Call_Io_Table::drop_table();
PRV_Call_Meta_Table::drop_table();
$dropped = $GLOBALS['prv_test_state']['wpdb_dropped_tables'];
$this->assertContains( 'prv_call_io', $dropped, 'drop_table() drops prv_call_io' );
$this->assertContains( 'prv_call_meta', $dropped, 'drop_table() drops prv_call_meta' );
$this->assertContains( 'prv_ai_visibility', $dropped, 'drop_table() drops prv_ai_visibility (existing table)' );
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* Tests for PRV_Cost_Ledger: MTD cost retrieval, budget pre-check,
* and the budget-cap abort behaviour.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Cost_Ledger
*/
class CostLedgerTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_can_afford_below_cap(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '2.50';
$ledger = new PRV_Cost_Ledger();
$this->assertTrue( $ledger->can_afford( 1.0 ), 'can_afford: 2.50 spent + 1.00 = 3.50 < 5.00 → true' );
}
public function test_can_afford_at_cap_returns_false(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '5.00';
$ledger = new PRV_Cost_Ledger();
$this->assertFalse( $ledger->can_afford( 0.01 ), 'can_afford: 5.00 spent + 0.01 > 5.00 → false' );
}
public function test_can_afford_exactly_at_cap_is_allowed(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '4.99';
$ledger = new PRV_Cost_Ledger();
$this->assertTrue( $ledger->can_afford( 0.01 ), 'can_afford: 4.99 + 0.01 = 5.00 exactly → true' );
}
public function test_get_remaining_budget_usd_correct_value(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '3.25';
$ledger = new PRV_Cost_Ledger();
$this->assertSame( 1.75, round( $ledger->get_remaining_budget_usd(), 2 ), 'get_remaining_budget_usd: 5.00 - 3.25 = 1.75' );
}
public function test_get_remaining_budget_never_negative(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '10.00';
$ledger = new PRV_Cost_Ledger();
$this->assertSame( 0.0, $ledger->get_remaining_budget_usd(), 'get_remaining_budget_usd: clamps to 0.0 when over cap' );
}
public function test_get_month_to_date_usd_null_returns_zero(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = null;
$ledger = new PRV_Cost_Ledger();
$this->assertSame( 0.0, $ledger->get_month_to_date_usd(), 'get_month_to_date_usd: null DB result → 0.0' );
}
public function test_update_row_cost_returns_true(): void {
$ledger = new PRV_Cost_Ledger();
$this->assertTrue( $ledger->update_row_cost( 42, 0.00123456 ), 'update_row_cost: returns true on success' );
}
public function test_budget_abort_can_afford_false_at_cap(): void {
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 0.001;
$GLOBALS['prv_test_state']['wpdb_var'] = '0.001';
$ledger = new PRV_Cost_Ledger();
$this->assertFalse( $ledger->can_afford( 0.000001 ), 'budget abort: can_afford returns false at cap → runner would stop' );
}
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* Tests for PRV_Cron_Guard: self-healing cron scheduling.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Cron_Guard
*/
class CronGuardTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_ensure_scheduled_schedules_weekly_hook(): void {
$this->assertFalse( wp_next_scheduled( PRV_CRON_HOOK ), 'guard/weekly: not scheduled before call' );
$guard = new PRV_Cron_Guard();
$guard->ensure_scheduled();
$this->assertNotFalse( wp_next_scheduled( PRV_CRON_HOOK ), 'guard/weekly: prv_weekly_probe scheduled after ensure_scheduled()' );
}
public function test_ensure_scheduled_schedules_prune_hook(): void {
$this->assertFalse( wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK ), 'guard/prune: not scheduled before call' );
$guard = new PRV_Cron_Guard();
$guard->ensure_scheduled();
$this->assertNotFalse( wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK ), 'guard/prune: prv_daily_prune scheduled after ensure_scheduled()' );
}
public function test_ensure_scheduled_is_idempotent(): void {
$guard = new PRV_Cron_Guard();
$guard->ensure_scheduled();
$ts_weekly_1 = wp_next_scheduled( PRV_CRON_HOOK );
$ts_prune_1 = wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK );
$guard->ensure_scheduled(); // Second call.
$ts_weekly_2 = wp_next_scheduled( PRV_CRON_HOOK );
$ts_prune_2 = wp_next_scheduled( PRV_Prune_Cron::PRUNE_HOOK );
$this->assertSame( $ts_weekly_1, $ts_weekly_2, 'guard/idempotent: weekly event timestamp unchanged after second call' );
$this->assertSame( $ts_prune_1, $ts_prune_2, 'guard/idempotent: prune event timestamp unchanged after second call' );
$cron_events = $GLOBALS['prv_test_state']['cron_events'];
$this->assertArrayHasKey( PRV_CRON_HOOK, $cron_events, 'guard/idempotent: exactly one weekly event entry' );
$this->assertArrayHasKey( PRV_Prune_Cron::PRUNE_HOOK, $cron_events, 'guard/idempotent: exactly one prune event entry' );
$this->assertCount( 2, $cron_events, 'guard/idempotent: only two cron event entries total' );
}
public function test_guard_uses_same_recurrence_as_canonical_schedulers(): void {
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule();
$canonical_weekly = $GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ]['schedule'] ?? null;
prv_test_reset();
PRV_Prune_Cron::schedule();
$canonical_prune = $GLOBALS['prv_test_state']['cron_events'][ PRV_Prune_Cron::PRUNE_HOOK ]['schedule'] ?? null;
prv_test_reset();
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$guard2 = new PRV_Cron_Guard();
$guard2->ensure_scheduled();
$guard_weekly = $GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ]['schedule'] ?? null;
$guard_prune = $GLOBALS['prv_test_state']['cron_events'][ PRV_Prune_Cron::PRUNE_HOOK ]['schedule'] ?? null;
$this->assertSame( $canonical_weekly, $guard_weekly, 'guard/recurrence: weekly matches canonical PRV_Cron::schedule()' );
$this->assertSame( $canonical_prune, $guard_prune, 'guard/recurrence: prune matches canonical PRV_Prune_Cron::schedule()' );
}
public function test_ensure_scheduled_noop_when_both_already_scheduled(): void {
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule();
PRV_Prune_Cron::schedule();
$before = $GLOBALS['prv_test_state']['cron_events'];
$guard3 = new PRV_Cron_Guard();
$guard3->ensure_scheduled();
$after = $GLOBALS['prv_test_state']['cron_events'];
$this->assertSame( $before, $after, 'guard/noop: cron_events unchanged when all events already scheduled' );
}
public function test_register_hooks_adds_init_action(): void {
$guard4 = new PRV_Cron_Guard();
$guard4->register_hooks();
$hooks = array_column( $GLOBALS['prv_test_state']['actions'], 'hook' );
$this->assertContains( 'init', $hooks, 'guard/register_hooks: init action registered' );
}
}

View File

@@ -0,0 +1,92 @@
<?php
/**
* Tests for PRV_Cron cadence reschedule on config change.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Cron
* @covers PRV_Config
*/
class CronRescheduleTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_schedule_weekly_with_configured_cadence(): void {
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule_weekly();
$this->assertTrue( PRV_Cron::is_scheduled(), 'schedule: event scheduled' );
$next = PRV_Cron::next_run_timestamp();
$this->assertNotFalse( $next, 'schedule: next_run_timestamp is not false' );
$this->assertGreaterThan( 0, (int) $next, 'schedule: next_run_timestamp is positive' );
}
public function test_reschedule_clears_old_and_adds_new(): void {
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule();
$this->assertTrue( PRV_Cron::is_scheduled(), 'reschedule pre: event is scheduled' );
PRV_Cron::reschedule( 'daily' );
$this->assertTrue( PRV_Cron::is_scheduled(), 'reschedule post: event is still scheduled after reschedule' );
$events = _get_cron_array();
$found_daily = false;
foreach ( $events as $hooks ) {
if ( isset( $hooks[ PRV_CRON_HOOK ] ) ) {
foreach ( $hooks[ PRV_CRON_HOOK ] as $evt ) {
if ( 'daily' === ( $evt['schedule'] ?? '' ) ) {
$found_daily = true;
}
}
}
}
$this->assertTrue( $found_daily, 'reschedule: new cron has daily recurrence' );
}
public function test_clear_schedule_removes_event(): void {
PRV_Cron::schedule();
$this->assertTrue( PRV_Cron::is_scheduled(), 'clear pre: event is scheduled' );
PRV_Cron::clear_schedule();
$this->assertFalse( PRV_Cron::is_scheduled(), 'clear: event removed after clear_schedule' );
}
public function test_schedule_is_idempotent(): void {
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule();
$ts1 = PRV_Cron::next_run_timestamp();
PRV_Cron::schedule();
$ts2 = PRV_Cron::next_run_timestamp();
$this->assertSame( $ts1, $ts2, 'schedule idempotent: same timestamp after two schedule() calls at same cadence' );
}
public function test_get_cadence_defaults_to_weekly(): void {
$cadence = PRV_Config::get_cadence();
$this->assertSame( 'weekly', $cadence, 'get_cadence: defaults to weekly' );
}
public function test_invalid_cadence_falls_back_to_weekly(): void {
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'invalid_cadence';
$cadence = PRV_Config::get_cadence();
$this->assertSame( 'weekly', $cadence, 'get_cadence: invalid value falls back to weekly' );
}
public function test_cron_tick_skipped_when_run_lock_held(): void {
PRV_Run_Lock::acquire();
$cron = new PRV_Cron();
$cron->handle_cron_tick();
$this->assertTrue( PRV_Run_Lock::is_locked(), 'cron tick: lock still held (run was skipped)' );
PRV_Run_Lock::release();
$this->assertFalse( PRV_Run_Lock::is_locked(), 'cron tick cleanup: lock released' );
}
}

68
tests/unit/CronTest.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
/**
* Tests for PRV_Cron: scheduling, clearing, and registration.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Cron
* @covers PRV_Activator
* @covers PRV_Deactivator
*/
class CronTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_schedule_weekly_adds_event(): void {
$this->assertFalse( PRV_Cron::is_scheduled() );
PRV_Cron::schedule_weekly();
$this->assertTrue( PRV_Cron::is_scheduled() );
}
public function test_schedule_weekly_is_idempotent(): void {
PRV_Cron::schedule_weekly();
PRV_Cron::schedule_weekly();
$events = $GLOBALS['prv_test_state']['cron_events'];
$this->assertCount( 1, $events );
}
public function test_clear_schedule_removes_event(): void {
PRV_Cron::schedule_weekly();
PRV_Cron::clear_schedule();
$this->assertFalse( PRV_Cron::is_scheduled() );
}
public function test_clear_schedule_safe_when_nothing_scheduled(): void {
PRV_Cron::clear_schedule();
$this->assertFalse( PRV_Cron::is_scheduled() );
}
public function test_register_hooks_registers_cron_hook_action(): void {
$cron = new PRV_Cron();
$cron->register_hooks();
$hooks = array_column( $GLOBALS['prv_test_state']['actions'], 'hook' );
$this->assertContains( PRV_CRON_HOOK, $hooks );
}
public function test_prv_cron_hook_constant_value(): void {
$this->assertSame( 'prv_weekly_probe', PRV_CRON_HOOK );
}
public function test_activator_schedules_cron(): void {
PRV_Activator::activate();
$this->assertTrue( PRV_Cron::is_scheduled() );
}
public function test_deactivator_clears_cron(): void {
PRV_Activator::activate();
PRV_Deactivator::deactivate();
$this->assertFalse( PRV_Cron::is_scheduled() );
}
}

View File

@@ -0,0 +1,152 @@
<?php
/**
* Tests for PRV_Ai_Visibility_Panel and PRV_Dashboard_Renderer data-shaping.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class DashboardPanelTest extends TestCase {
private PRV_Ai_Visibility_Panel $panel;
protected function setUp(): void {
prv_test_reset();
$this->panel = new PRV_Ai_Visibility_Panel();
}
private function callPrivate( object $obj, string $method, array $args = [] ): mixed {
$ref = new ReflectionMethod( $obj, $method );
$ref->setAccessible( true );
return $ref->invokeArgs( $obj, $args );
}
public function test_health_pill_empty_map_is_neutral(): void {
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [ [] ] );
$this->assertSame( 'neutral', $result['state'] );
}
public function test_health_pill_all_healthy_is_ok(): void {
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [
[ 'a' => [ 'health_status' => 'healthy' ], 'b' => [ 'health_status' => 'healthy' ] ],
] );
$this->assertSame( 'ok', $result['state'] );
$this->assertStringContainsString( 'healthy', $result['label'] );
}
public function test_health_pill_one_retired_is_warn(): void {
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [
[ 'a' => [ 'health_status' => 'healthy' ], 'b' => [ 'health_status' => 'retired' ] ],
] );
$this->assertSame( 'warn', $result['state'] );
$this->assertStringContainsString( '1', $result['label'] );
}
public function test_health_pill_disabled_only_is_neutral(): void {
$result = $this->callPrivate( $this->panel, 'derive_health_pill_state', [
[ 'a' => [ 'health_status' => 'disabled' ] ],
] );
$this->assertSame( 'neutral', $result['state'] );
}
public function test_build_delta_html_positive_gets_up_class(): void {
$renderer = new PRV_Dashboard_Renderer();
$out = $renderer->build_delta_html( 0.10, 0.08 );
$this->assertStringContainsString( 'prv-delta--up', $out );
$this->assertStringContainsString( '+', $out );
$this->assertStringContainsString( '2.00', $out );
}
public function test_build_delta_html_negative_gets_down_class(): void {
$renderer = new PRV_Dashboard_Renderer();
$this->assertStringContainsString( 'prv-delta--down', $renderer->build_delta_html( 0.05, 0.08 ) );
}
public function test_build_delta_html_zero_gets_flat_class(): void {
$renderer = new PRV_Dashboard_Renderer();
$this->assertStringContainsString( 'prv-delta--flat', $renderer->build_delta_html( 0.08, 0.08 ) );
}
public function test_build_delta_html_null_previous_returns_empty(): void {
$renderer = new PRV_Dashboard_Renderer();
$this->assertSame( '', $renderer->build_delta_html( 0.08, null ) );
}
public function test_collector_trendline_includes_config_version(): void {
// Collector calls get_results() twice: (1) trendline, (2) standings.
// Use wpdb_results_queue to provide separate row sets for each call.
$GLOBALS['prv_test_state']['wpdb_results_queue'] = [
// 1st call: trendline rows.
[
[ 'run_id' => 'run-001', 'captured_at' => '2026-06-01 10:00:00', 'cited_count' => 2, 'total_count' => 10, 'position_sum' => 0.5, 'config_version' => 1 ],
[ 'run_id' => 'run-002', 'captured_at' => '2026-06-08 10:00:00', 'cited_count' => 3, 'total_count' => 10, 'position_sum' => 0.6, 'config_version' => 2 ],
],
// 2nd call: standings rows (empty — not needed for this assertion).
[],
];
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-08 10:00:00';
$data = ( new PRV_Ai_Visibility_Collector() )->collect();
$this->assertArrayHasKey( 'trendline', $data );
$this->assertCount( 2, $data['trendline'] );
$this->assertSame( 1, $data['trendline'][0]['config_version'] );
$this->assertSame( 2, $data['trendline'][1]['config_version'] );
}
public function test_collector_payload_has_new_keys(): void {
// Both get_results() calls return [] (no data needed for key-presence check).
$GLOBALS['prv_test_state']['wpdb_results'] = [];
$GLOBALS['prv_test_state']['wpdb_var'] = null;
$data = ( new PRV_Ai_Visibility_Collector() )->collect();
$this->assertArrayHasKey( 'last_run_counts', $data );
$this->assertIsArray( $data['last_run_counts'] );
$this->assertArrayHasKey( 'config_versions', $data );
$this->assertIsArray( $data['config_versions'] );
}
public function test_null_config_version_preserved(): void {
$GLOBALS['prv_test_state']['wpdb_results_queue'] = [
// 1st call: trendline rows.
[ [ 'run_id' => 'run-001', 'captured_at' => '2026-06-01 10:00:00', 'cited_count' => 1, 'total_count' => 5, 'position_sum' => 0.25, 'config_version' => null ] ],
// 2nd call: standings rows (empty).
[],
];
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-01 10:00:00';
$data = ( new PRV_Ai_Visibility_Collector() )->collect();
$this->assertCount( 1, $data['trendline'] );
$this->assertNull( $data['trendline'][0]['config_version'] );
}
public function test_single_version_trendline_produces_no_cfg_note(): void {
$renderer = new PRV_Dashboard_Renderer();
ob_start();
$renderer->render_trendline( [
[ 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ],
[ 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 1 ],
] );
$out = (string) ob_get_clean();
$this->assertStringNotContainsString( 'prv-cfg-note', $out );
$this->assertStringContainsString( 'prv-trendline-chart', $out );
}
public function test_two_version_trendline_produces_cfg_note(): void {
$renderer = new PRV_Dashboard_Renderer();
ob_start();
$renderer->render_trendline( [
[ 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ],
[ 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 2 ],
] );
$out = (string) ob_get_clean();
$this->assertStringContainsString( 'prv-cfg-note', $out );
$this->assertStringContainsString( 'configMarker', $out );
$this->assertStringContainsString( 'prv-leg-cfg', $out );
$this->assertStringContainsString( 'prv-noscript', $out );
$this->assertStringContainsString( 'prv-chart-fallback', $out );
}
}

172
tests/unit/KeyStoreTest.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
/**
* Tests for PRV_Key_Store and PRV_Crypto_Helper.
*
* Covers: encrypt/decrypt round-trip, resolver precedence,
* write-only render, and Remove (clear_key).
*
* Security assertions (P0): plaintext never in stored option,
* password input renders with empty value, wrong-key decrypts to empty.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Key_Store
* @covers PRV_Crypto_Helper
*/
class KeyStoreTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
// ── Group 1: PRV_Crypto_Helper encrypt/decrypt round-trip ────────────
public function test_derive_encryption_key_returns_32_bytes(): void {
$key = PRV_Key_Store::derive_encryption_key();
$this->assertSame( 32, strlen( $key ), 'derive_encryption_key() returns 32-byte key' );
}
public function test_encrypt_returns_non_empty_and_opaque(): void {
$plaintext = 'sk-or-test-key-value-abc123';
$enc_key = PRV_Key_Store::derive_encryption_key();
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
$this->assertNotEmpty( $encrypted, 'encrypt() returns non-empty string' );
$this->assertNotSame( $plaintext, $encrypted, 'encrypt() does not return plaintext' );
$this->assertStringNotContainsString( $plaintext, $encrypted, 'Plaintext not visible in ciphertext' );
}
public function test_decrypt_recovers_plaintext(): void {
$plaintext = 'sk-or-test-key-value-abc123';
$enc_key = PRV_Key_Store::derive_encryption_key();
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
$decrypted = PRV_Crypto_Helper::decrypt( $encrypted, $enc_key );
$this->assertSame( $plaintext, $decrypted, 'decrypt(encrypt(key)) === original key' );
}
public function test_each_encryption_produces_unique_ciphertext(): void {
$plaintext = 'sk-or-test-key-value-abc123';
$enc_key = PRV_Key_Store::derive_encryption_key();
$ct1 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
$ct2 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
$this->assertNotSame( $ct1, $ct2, 'Two encryptions differ (random nonce)' );
$this->assertSame( $plaintext, PRV_Crypto_Helper::decrypt( $ct2, $enc_key ), 'Second ciphertext also decrypts correctly' );
}
public function test_decrypt_rejects_malformed_inputs(): void {
$enc_key = PRV_Key_Store::derive_encryption_key();
$this->assertSame( '', PRV_Crypto_Helper::decrypt( '', $enc_key ), 'decrypt("") returns empty' );
$this->assertSame( '', PRV_Crypto_Helper::decrypt( 'garbage', $enc_key ), 'decrypt(garbage) returns empty' );
$this->assertSame( '', PRV_Crypto_Helper::decrypt( 'aabb:', $enc_key ), 'decrypt(empty cipher) returns empty' );
}
public function test_decrypt_wrong_key_returns_empty(): void {
$plaintext = 'sk-or-test-key-value-abc123';
$enc_key = PRV_Key_Store::derive_encryption_key();
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
$wrong_key = hash( 'sha256', 'wrong-salt', true );
$this->assertSame( '', PRV_Crypto_Helper::decrypt( $encrypted, $wrong_key ), 'decrypt with wrong key returns empty' );
}
// ── Group 2: store_key / clear_key / get_source ──────────────────────
public function test_get_source_constant_when_constant_defined(): void {
// PRV_OPENROUTER_API_KEY is defined in tests/bootstrap.php as 'sk-or-test-key'.
// The constant always wins over option, so get_source() returns SOURCE_CONSTANT.
$this->assertTrue( PRV_Key_Store::constant_is_set(), 'constant_is_set() true when constant defined in bootstrap' );
$this->assertSame( PRV_Key_Store::SOURCE_CONSTANT, PRV_Key_Store::get_source(), 'get_source() == CONSTANT when constant defined' );
}
public function test_store_key_writes_encrypted_option(): void {
$plaintext = 'sk-or-test-key-value-abc123';
$stored = PRV_Key_Store::store_key( $plaintext );
$this->assertTrue( $stored, 'store_key() returns true on success' );
$option_val = get_option( PRV_Key_Store::OPTION_ENC, '' );
$this->assertNotEmpty( $option_val, 'Option is set after store_key()' );
$this->assertNotSame( $plaintext, $option_val, 'Option value is NOT the plaintext key (encrypted at rest)' );
$this->assertStringNotContainsString( $plaintext, (string) $option_val, 'Plaintext not in stored option value' );
$this->assertStringNotContainsString( 'sk-or-', (string) $option_val, 'Key prefix not in stored option' );
// Note: get_source() returns SOURCE_CONSTANT (constant wins) even after store_key(),
// because PRV_OPENROUTER_API_KEY is defined in bootstrap. The option IS stored.
$this->assertSame( PRV_Key_Store::SOURCE_CONSTANT, PRV_Key_Store::get_source(), 'Constant wins over stored option' );
}
public function test_clear_key_removes_option(): void {
PRV_Key_Store::store_key( 'sk-or-test-key-value-abc123' );
PRV_Key_Store::clear_key();
$this->assertSame( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option is empty after clear_key()' );
// Constant is still set after clear_key() - SOURCE_CONSTANT, not SOURCE_NONE.
$this->assertSame( PRV_Key_Store::SOURCE_CONSTANT, PRV_Key_Store::get_source(), 'Source remains CONSTANT after clear_key() since constant stays defined' );
}
public function test_store_empty_key_is_noop(): void {
$result = PRV_Key_Store::store_key( '' );
$this->assertFalse( $result, 'store_key("") returns false' );
$this->assertSame( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option unchanged after store_key("")' );
}
// ── Group 3: Resolver precedence — constant > option > none ──────────
public function test_get_key_returns_constant_value(): void {
// PRV_OPENROUTER_API_KEY='sk-or-test-key' is defined in bootstrap.
// get_key() returns the constant value (highest precedence).
$this->assertSame( 'sk-or-test-key', PRV_Key_Store::get_key(), 'get_key() returns the wp-config constant value' );
}
public function test_get_key_constant_wins_over_option(): void {
// store_key() writes a different plaintext to the encrypted option.
// But get_key() still returns the constant because constant > option.
$plaintext = 'sk-or-test-key-value-abc123';
PRV_Key_Store::store_key( $plaintext );
$this->assertSame( 'sk-or-test-key', PRV_Key_Store::get_key(), 'Constant wins over stored option (constant > option precedence)' );
}
// ── Group 4: Write-only — key value never in rendered HTML output ─────
public function test_rendered_html_never_contains_key(): void {
// P0 invariant: key value NEVER appears in any rendered HTML output.
// With PRV_OPENROUTER_API_KEY constant in bootstrap, source = SOURCE_CONSTANT.
// The renderer must still render the password input with value="" (write-only).
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-settings-page.php';
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-manager-renderer.php';
ob_start();
( new PRV_Key_Manager_Renderer() )->render();
$html = (string) ob_get_clean();
$this->assertNotEmpty( $html, 'Key manager card renders non-empty HTML' );
// The constant key 'sk-or-test-key' must NOT appear in the HTML (P0 write-only).
$this->assertStringNotContainsString( 'sk-or-test-key', $html, 'Constant API key value absent from rendered HTML (P0)' );
$this->assertStringContainsString( 'type="password"', $html, 'Password input is present in output' );
$this->assertStringContainsString( 'value=""', $html, 'Password input has empty value attribute (write-only)' );
$this->assertStringNotContainsString( 'value="sk-', $html, 'No key value in any value= attribute' );
}
// ── Group 5: Remove clears the encrypted option ───────────────────────
public function test_clear_key_empties_option(): void {
$plaintext = 'sk-or-test-key-value-abc123';
PRV_Key_Store::store_key( $plaintext );
$this->assertNotEmpty( get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Key present before remove' );
PRV_Key_Store::clear_key();
$this->assertSame( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option empty after clear_key()' );
}
}

View File

@@ -0,0 +1,146 @@
<?php
/**
* Tests for PRV_Model_Registry: v1->v2 migration, CRUD, run-health.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Model_Registry
*/
class ModelRegistryTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_migration_v1_to_v2_preserves_live_models(): void {
$GLOBALS['prv_test_state']['options']['prv_models'] = [
'perplexity/sonar',
'openai/gpt-4o-search-preview',
'google/gemini-2.0-flash-001',
];
$migrated = PRV_Model_Registry::run_migration_v2();
$this->assertTrue( $migrated );
$models = PRV_Model_Registry::get_all();
$this->assertCount( 3, $models );
$slugs = array_column( $models, 'slug' );
$this->assertContains( 'perplexity/sonar', $slugs );
$this->assertContains( 'openai/gpt-4o-search-preview', $slugs );
$this->assertContains( 'google/gemini-2.0-flash-001', $slugs );
foreach ( $models as $m ) {
$this->assertArrayHasKey( 'id', $m );
$this->assertArrayHasKey( 'provider', $m );
$this->assertArrayHasKey( 'health_status', $m );
$this->assertTrue( $m['enabled'] );
}
$this->assertSame(
PRV_Model_Registry::SCHEMA_VERSION,
(int) $GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ]
);
}
public function test_migration_is_idempotent(): void {
$GLOBALS['prv_test_state']['options']['prv_models'] = [ 'perplexity/sonar' ];
PRV_Model_Registry::run_migration_v2();
$before_count = count( PRV_Model_Registry::get_all() );
$second_run = PRV_Model_Registry::run_migration_v2();
$this->assertFalse( $second_run );
$this->assertSame( $before_count, count( PRV_Model_Registry::get_all() ) );
}
public function test_migration_seeds_defaults_when_no_models(): void {
$migrated = PRV_Model_Registry::run_migration_v2();
$this->assertTrue( $migrated );
$this->assertGreaterThanOrEqual( 3, count( PRV_Model_Registry::get_all() ) );
}
public function test_add_model(): void {
PRV_Model_Registry::run_migration_v2();
$base_count = count( PRV_Model_Registry::get_all() );
$new_id = PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'Test model' );
$this->assertNotEmpty( $new_id );
$this->assertStringStartsWith( 'mdl_', $new_id );
$this->assertCount( $base_count + 1, PRV_Model_Registry::get_all() );
$found = PRV_Model_Registry::find_by_id( $new_id );
$this->assertNotNull( $found );
$this->assertSame( 'anthropic/claude-3-haiku', $found['slug'] );
$this->assertSame( 'Test model', $found['note'] );
}
public function test_update_model(): void {
PRV_Model_Registry::run_migration_v2();
$target = PRV_Model_Registry::get_all()[0];
$result = PRV_Model_Registry::update( $target['id'], [ 'enabled' => false, 'note' => 'Disabled for test' ] );
$this->assertTrue( $result );
$updated = PRV_Model_Registry::find_by_id( $target['id'] );
$this->assertFalse( $updated['enabled'] );
$this->assertSame( 'Disabled for test', $updated['note'] );
}
public function test_remove_model(): void {
PRV_Model_Registry::run_migration_v2();
$all = PRV_Model_Registry::get_all();
$id = $all[0]['id'];
$count_before = count( $all );
$removed = PRV_Model_Registry::remove( $id );
$this->assertTrue( $removed );
$this->assertCount( $count_before - 1, PRV_Model_Registry::get_all() );
$this->assertNull( PRV_Model_Registry::find_by_id( $id ) );
}
public function test_remove_nonexistent_returns_false(): void {
PRV_Model_Registry::run_migration_v2();
$this->assertFalse( PRV_Model_Registry::remove( 'mdl_doesnotexist' ) );
}
public function test_get_enabled_slugs_excludes_disabled(): void {
PRV_Model_Registry::run_migration_v2();
$all = PRV_Model_Registry::get_all();
PRV_Model_Registry::update( $all[0]['id'], [ 'enabled' => false ] );
$slugs = PRV_Model_Registry::get_enabled_slugs();
$this->assertNotContains( $all[0]['slug'], $slugs );
$this->assertCount( count( $all ) - 1, $slugs );
}
public function test_update_health_sets_correct_status(): void {
PRV_Model_Registry::run_migration_v2();
$all = PRV_Model_Registry::get_all();
$slug0 = $all[0]['slug'];
$slug1 = $all[1]['slug'] ?? null;
$run_id = 'test-run-' . uniqid( '', true );
$outcomes = [ $slug0 => [ 'probed' => 5, 'errors' => 0 ] ];
if ( $slug1 ) {
$outcomes[ $slug1 ] = [ 'probed' => 0, 'errors' => 3 ];
}
PRV_Model_Registry::update_health( $run_id, $outcomes );
foreach ( PRV_Model_Registry::get_all() as $m ) {
if ( $m['slug'] === $slug0 ) {
$this->assertSame( 'healthy', $m['health_status'] );
$this->assertSame( 5, $m['health_probed'] );
$this->assertSame( $run_id, $m['health_run_id'] );
}
if ( $slug1 && $m['slug'] === $slug1 ) {
$this->assertSame( 'retired', $m['health_status'] );
$this->assertSame( 3, $m['health_errors'] );
}
}
}
}

View File

@@ -0,0 +1,149 @@
<?php
/**
* Tests for PRV_Probe_Runner: budget-cap abort, run-lock, model health.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class PRV_Mock_Ledger extends PRV_Cost_Ledger {
public int $afford_count = 0;
public int $no_afford_count = 0;
public int $exhaust_after = PHP_INT_MAX;
public function can_afford( float $estimated ): bool {
$total = $this->afford_count + $this->no_afford_count;
if ( $total >= $this->exhaust_after ) {
$this->no_afford_count++;
return false;
}
$this->afford_count++;
return true;
}
public function update_row_cost( int $row_id, float $cost ): bool { return true; }
public function get_month_to_date_usd(): float { return 0.0; }
}
/**
* @covers PRV_Probe_Runner
*/
class ProbeRunnerTest extends TestCase {
private array $sonar_row;
private string $cited_body;
protected function setUp(): void {
prv_test_reset();
$this->sonar_row = [ 'id' => 'mdl_t1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ];
$this->cited_body = json_encode( [ 'choices' => [ [ 'message' => [ 'content' => 'BPC-157 info.' ] ] ], 'citations' => [ 'https://peptiderepo.com/bpc-157/', 'https://examine.com' ], 'usage' => [ 'total_tokens' => 100 ] ] );
}
private function setupRun( array $model_rows, array $peptides = null, array $intents = null ): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$GLOBALS['prv_test_state']['options']['prv_models'] = $model_rows;
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = $peptides ?? [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = $intents ?? [ 'what is {peptide}' ];
}
public function test_basic_run_1x1x1(): void {
$this->setupRun( [ $this->sonar_row ] );
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
$ledger = new PRV_Mock_Ledger();
$counts = ( new PRV_Probe_Runner( $ledger ) )->run();
$this->assertSame( 1, $counts['probed'] );
$this->assertSame( 0, $counts['skipped_budget'] );
$this->assertSame( 0, $counts['skipped_error'] );
$this->assertSame( 1, $ledger->afford_count );
$this->assertNotEmpty( $counts['run_id'] );
$this->assertFalse( $counts['truncated'] );
}
public function test_budget_cap_abort(): void {
$this->setupRun(
[ $this->sonar_row ],
[ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ], [ 'slug' => 'tb-500', 'label' => 'TB-500' ], [ 'slug' => 'mk-677', 'label' => 'MK-677' ] ]
);
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] );
$exh = new PRV_Mock_Ledger();
$exh->exhaust_after = 1;
$counts = ( new PRV_Probe_Runner( $exh ) )->run();
$this->assertSame( 1, $counts['probed'] );
$this->assertGreaterThanOrEqual( 2, $counts['skipped_budget'] );
$this->assertSame( 0, $counts['skipped_error'] );
$this->assertTrue( $counts['truncated'] );
}
public function test_http_error_counts_as_skipped_error(): void {
$this->setupRun( [ $this->sonar_row ] );
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, [ 'response' => [ 'code' => 500 ], 'body' => 'Error' ] );
$counts = ( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
$this->assertSame( 0, $counts['probed'] );
$this->assertGreaterThanOrEqual( 1, $counts['skipped_error'] );
}
public function test_wp_error_counts_as_skipped_error(): void {
$this->setupRun( [ $this->sonar_row ] );
$wp_error = new WP_Error( 'http_request_failed', 'cURL error 28' );
$GLOBALS['prv_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ];
$counts = ( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
$this->assertSame( 0, $counts['probed'] );
$this->assertGreaterThanOrEqual( 1, $counts['skipped_error'] );
}
public function test_run_returns_all_required_keys(): void {
$this->setupRun( [ $this->sonar_row ] );
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
$counts = ( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
foreach ( [ 'probed', 'skipped_budget', 'skipped_error', 'truncated', 'run_id' ] as $key ) {
$this->assertArrayHasKey( $key, $counts );
}
}
public function test_run_lock_refused_when_locked(): void {
$this->setupRun( [ $this->sonar_row ] );
PRV_Run_Lock::acquire();
$ledger = new PRV_Mock_Ledger();
$counts = ( new PRV_Probe_Runner( $ledger ) )->run();
$this->assertSame( 0, $counts['probed'] );
$this->assertSame( -1, $counts['skipped_error'] );
$this->assertSame( 0, $ledger->afford_count );
PRV_Run_Lock::release();
}
public function test_health_updated_after_successful_run(): void {
$this->setupRun( [ $this->sonar_row ] );
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
foreach ( PRV_Model_Registry::get_all() as $m ) {
if ( 'perplexity/sonar' === $m['slug'] ) {
$this->assertSame( 'healthy', $m['health_status'] );
$this->assertSame( 1, $m['health_probed'] );
$this->assertSame( 0, $m['health_errors'] );
}
}
}
public function test_lock_released_after_successful_run(): void {
$this->setupRun( [ $this->sonar_row ] );
$GLOBALS['prv_test_state']['remote_posts'] = [ [ 'response' => [ 'code' => 200 ], 'body' => $this->cited_body ] ];
( new PRV_Probe_Runner( new PRV_Mock_Ledger() ) )->run();
$this->assertFalse( PRV_Run_Lock::is_locked() );
}
}

View File

@@ -0,0 +1,104 @@
<?php
/**
* Tests for projected cost calculation and budget cap logic.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class PRV_Exhausting_Ledger extends PRV_Cost_Ledger {
private int $count = 0;
public function can_afford( float $e ): bool { return $this->count++ < 1; }
public function update_row_cost( int $id, float $c ): bool { return true; }
public function get_month_to_date_usd(): float { return 4.99; }
}
/**
* @covers PRV_Config
*/
class ProjectedCostTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_projected_cost_reflects_enabled_models_x_peptides_x_intents(): void {
PRV_Model_Registry::run_migration_v2();
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_a1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_a2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ], [ 'slug' => 'tb-500', 'label' => 'TB-500' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}', '{peptide} dosage' ];
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$cost = PRV_Config::get_projected_cost();
$this->assertSame( 8, $cost['probe_count'], '2 models x 2 peptides x 2 intents = 8' );
$this->assertGreaterThan( 0.0, $cost['per_run_usd'] );
$this->assertGreaterThan( 0.0, $cost['per_month_usd'] );
$this->assertGreaterThanOrEqual( $cost['per_run_usd'], $cost['per_month_usd'] );
$this->assertFalse( $cost['over_cap'] );
}
public function test_over_cap_flag_fires_when_projected_exceeds_cap(): void {
PRV_Model_Registry::run_migration_v2();
$big = [];
for ( $i = 0; $i < 100; $i++ ) {
$big[] = [ 'slug' => "pep-{$i}", 'label' => "Peptide {$i}" ];
}
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_b1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_b2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_b3', 'slug' => 'google/gemini-2.0-flash-001', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = $big;
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}', '{peptide} dosage', '{peptide} guide' ];
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$cost = PRV_Config::get_projected_cost();
$this->assertTrue( $cost['over_cap'] );
}
public function test_disabled_models_excluded_from_probe_count(): void {
PRV_Model_Registry::run_migration_v2();
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_c1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_c2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => false, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$cost = PRV_Config::get_projected_cost();
$this->assertSame( 1, $cost['probe_count'] );
}
public function test_truncation_flag_set_when_budget_hit_mid_run(): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [
[ 'slug' => 'bpc-157', 'label' => 'BPC-157' ],
[ 'slug' => 'tb-500', 'label' => 'TB-500' ],
[ 'slug' => 'mk-677', 'label' => 'MK-677' ],
];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
$cited_body = json_encode( [ 'choices' => [ [ 'message' => [ 'content' => 'test' ] ] ], 'citations' => [ 'https://peptiderepo.com/bpc-157/' ], 'usage' => [ 'total_tokens' => 50 ] ] );
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, [ 'response' => [ 'code' => 200 ], 'body' => $cited_body ] );
$counts = ( new PRV_Probe_Runner( new PRV_Exhausting_Ledger() ) )->run();
$this->assertTrue( $counts['truncated'] );
$this->assertSame( 1, (int) get_option( 'prv_last_run_truncated', 0 ) );
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* Tests for provider response parsing (Perplexity + OpenRouter).
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Perplexity_Provider
* @covers PRV_OpenRouter_Provider
*/
class ProviderParsingTest extends TestCase {
private PRV_Perplexity_Provider $perplexity;
private PRV_OpenRouter_Provider $openrouter;
protected function setUp(): void {
prv_test_reset();
$detector = new PRV_Citation_Detector();
$this->perplexity = new PRV_Perplexity_Provider( null, $detector );
$this->openrouter = new PRV_OpenRouter_Provider( 'openai/gpt-4o-search-preview', null, $detector );
}
public function test_perplexity_cited_via_citations_array(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'BPC-157 is a peptide.' ] ] ], 'citations' => [ 'https://peptiderepo.com/bpc-157/', 'https://examine.com/bpc-157' ], 'usage' => [ 'total_tokens' => 500 ] ];
$result = $this->perplexity->parse_response( $data );
$this->assertTrue( $result->is_cited() );
$this->assertSame( 1, $result->get_our_position() );
$this->assertCount( 2, $result->get_source_domains() );
$this->assertSame( 500 * 0.000001, $result->get_cost_usd() );
}
public function test_perplexity_not_cited_empty_citations(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'Info here.' ] ] ], 'citations' => [], 'usage' => [ 'total_tokens' => 200 ] ];
$result = $this->perplexity->parse_response( $data );
$this->assertFalse( $result->is_cited() );
$this->assertNull( $result->get_our_position() );
}
public function test_perplexity_excerpt_truncated_to_500_chars(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => str_repeat( 'a', 600 ) ] ] ], 'citations' => [] ];
$result = $this->perplexity->parse_response( $data );
$this->assertSame( 500, mb_strlen( $result->get_raw_excerpt() ) );
}
public function test_perplexity_object_style_citations(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'answer' ] ] ], 'citations' => [ [ 'url' => 'https://peptiderepo.com/mk-677/' ], [ 'url' => 'https://examine.com/mk677' ] ] ];
$result = $this->perplexity->parse_response( $data );
$this->assertTrue( $result->is_cited() );
$this->assertSame( 1, $result->get_our_position() );
}
public function test_perplexity_fallback_cost_when_no_usage(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'answer' ] ] ], 'citations' => [] ];
$result = $this->perplexity->parse_response( $data );
$this->assertSame( PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE, $result->get_cost_usd() );
}
public function test_openrouter_annotations_path(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'BPC-157 reconstitution.', 'annotations' => [ [ 'url_citation' => [ 'url' => 'https://peptiderepo.com/bpc-157-reconstitution/' ] ], [ 'url_citation' => [ 'url' => 'https://examine.com/bpc-157' ] ] ] ] ] ], 'usage' => [ 'total_tokens' => 300 ] ];
$result = $this->openrouter->parse_response( $data );
$this->assertTrue( $result->is_cited() );
$this->assertSame( 1, $result->get_our_position() );
}
public function test_openrouter_regex_fallback(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'See https://peptiderepo.com/tb-500/ and https://examine.com/tb-500 for details.' ] ] ], 'usage' => [ 'total_tokens' => 150 ] ];
$result = $this->openrouter->parse_response( $data );
$this->assertTrue( $result->is_cited() );
}
public function test_openrouter_not_cited(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'Some response without our domain.' ] ] ] ];
$result = $this->openrouter->parse_response( $data );
$this->assertFalse( $result->is_cited() );
}
public function test_openrouter_cost_from_token_usage(): void {
$data = [ 'choices' => [ [ 'message' => [ 'content' => 'test' ] ] ], 'usage' => [ 'total_tokens' => 1000 ] ];
$result = $this->openrouter->parse_response( $data );
$this->assertSame( 1000 * 0.000002, $result->get_cost_usd() );
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Tests for PRV_Run_Lock: acquire/release/is_locked concurrency guard.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Run_Lock
*/
class RunLockTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_acquire_returns_true_when_free(): void {
$this->assertTrue( PRV_Run_Lock::acquire() );
PRV_Run_Lock::release();
}
public function test_is_locked_true_after_acquire(): void {
PRV_Run_Lock::acquire();
$this->assertTrue( PRV_Run_Lock::is_locked() );
PRV_Run_Lock::release();
}
public function test_second_acquire_returns_false(): void {
PRV_Run_Lock::acquire();
$this->assertFalse( PRV_Run_Lock::acquire() );
PRV_Run_Lock::release();
}
public function test_release_clears_lock(): void {
PRV_Run_Lock::acquire();
PRV_Run_Lock::release();
$this->assertFalse( PRV_Run_Lock::is_locked() );
}
public function test_acquire_succeeds_again_after_release(): void {
PRV_Run_Lock::acquire();
PRV_Run_Lock::release();
$this->assertTrue( PRV_Run_Lock::acquire() );
PRV_Run_Lock::release();
}
public function test_locked_since_returns_null_when_not_locked(): void {
$this->assertNull( PRV_Run_Lock::locked_since() );
}
public function test_locked_since_non_null_when_locked(): void {
PRV_Run_Lock::acquire();
$since = PRV_Run_Lock::locked_since();
$this->assertNotNull( $since );
$this->assertGreaterThanOrEqual( 0, $since );
PRV_Run_Lock::release();
}
public function test_runner_refuses_when_locked(): void {
PRV_Model_Registry::run_migration_v2();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
PRV_Run_Lock::acquire();
$counts = ( new PRV_Probe_Runner( new PRV_Cost_Ledger() ) )->run();
$this->assertSame( 0, $counts['probed'] );
$this->assertSame( -1, $counts['skipped_error'] );
PRV_Run_Lock::release();
}
public function test_lock_released_after_runner_completes(): void {
PRV_Model_Registry::run_migration_v2();
$this->assertFalse( PRV_Run_Lock::is_locked() );
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* Tests for the visibility score formula in PRV_Ai_Visibility_Collector.
*
* Formula: score = round((cited/total + position_sum/total) / 2, 4)
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Ai_Visibility_Collector
*/
class ScoreCalcTest extends TestCase {
private PRV_Ai_Visibility_Collector $collector;
protected function setUp(): void {
prv_test_reset();
$this->collector = new PRV_Ai_Visibility_Collector( null );
}
public function test_zero_total_returns_zero(): void {
$this->assertSame( 0.0, $this->collector->compute_score( 0, 0, 0.0 ) );
}
public function test_all_cited_position_1_returns_1(): void {
$this->assertSame( 1.0, $this->collector->compute_score( 10, 10, 10.0 ) );
}
public function test_none_cited_returns_zero(): void {
$this->assertSame( 0.0, $this->collector->compute_score( 0, 10, 0.0 ) );
}
public function test_half_cited_at_position_2(): void {
// base=0.5, bonus=0.25, score=0.375
$this->assertSame( 0.375, $this->collector->compute_score( 5, 10, 2.5 ) );
}
public function test_single_probe_cited_at_position_3(): void {
// base=1.0, bonus=0.3333, score=0.6667
$this->assertSame( 0.6667, $this->collector->compute_score( 1, 1, 1.0 / 3.0 ) );
}
public function test_score_bounded_within_0_and_1(): void {
$this->assertLessThanOrEqual( 1.0, $this->collector->compute_score( 1, 1, 1.0 ) );
$this->assertGreaterThanOrEqual( 0.0, $this->collector->compute_score( 0, 10, 0.0 ) );
}
public function test_four_decimal_precision(): void {
$expected = round( ( 3.0 / 7.0 + 1.5 / 7.0 ) / 2.0, 4 );
$this->assertSame( $expected, $this->collector->compute_score( 3, 7, 1.5 ) );
}
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* Tests for uninstall purge: all prv_ options deleted, lock cleared.
*
* @package PrVision
*/
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers PRV_Activator
* @covers PRV_Deactivator
*/
class UninstallTest extends TestCase {
protected function setUp(): void {
prv_test_reset();
}
public function test_uninstall_purges_all_prv_options(): void {
// Populate all known prv_ options.
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
update_option( 'prv_monthly_budget_usd', 5.0 );
update_option( 'prv_peptides', [] );
update_option( 'prv_prompt_intents', [] );
update_option( PRV_Config::CADENCE_KEY, 'weekly' );
update_option( 'prv_api_key_status', 'ok' );
update_option( 'prv_api_key_last_check', '2026-06-14 12:00:00' );
update_option( 'prv_last_run_at', '2026-06-14 12:00:00' );
update_option( 'prv_last_run_counts', [] );
update_option( 'prv_last_run_truncated', 0 );
update_option( 'prv_last_run_truncated_at', '' );
update_option( 'prv_provider_key_enc', 'dummy-encrypted-ciphertext-value' );
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
$GLOBALS['prv_test_state']['transients'][ PRV_Run_Lock::LOCK_KEY ] = time();
$GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ] = [ 'timestamp' => time() + 3600, 'schedule' => 'weekly' ];
// Verify pre-uninstall state.
$this->assertNotFalse( get_option( 'prv_models' ) );
$this->assertNotFalse( get_option( 'prv_monthly_budget_usd' ) );
$this->assertNotFalse( get_option( 'prv_api_key_status' ) );
$this->assertNotFalse( get_option( 'prv_active_config_version' ) );
$this->assertNotFalse( get_transient( PRV_Run_Lock::LOCK_KEY ) );
$this->assertTrue( PRV_Cron::is_scheduled() );
// Simulate uninstall: delete all prv_ options.
$keys_to_purge = array_filter(
array_keys( $GLOBALS['prv_test_state']['options'] ),
static fn( $k ) => str_starts_with( $k, 'prv_' )
);
foreach ( $keys_to_purge as $k ) {
unset( $GLOBALS['prv_test_state']['options'][ $k ] );
}
delete_transient( PRV_Run_Lock::LOCK_KEY );
wp_clear_scheduled_hook( PRV_CRON_HOOK );
// Verify all prv_ options are gone.
$this->assertFalse( get_option( 'prv_models' ) );
$this->assertFalse( get_option( 'prv_monthly_budget_usd' ) );
$this->assertFalse( get_option( 'prv_api_key_status' ) );
$this->assertFalse( get_option( 'prv_active_config_version' ) );
$this->assertFalse( get_option( 'prv_config_versions' ) );
$this->assertFalse( get_option( 'prv_models_schema_version' ) );
$this->assertFalse( get_option( 'prv_schema_version' ) );
$this->assertFalse( get_option( 'prv_provider_key_enc' ) );
$this->assertFalse( get_option( 'prv_last_run_truncated' ) );
$this->assertFalse( get_transient( PRV_Run_Lock::LOCK_KEY ) );
$this->assertFalse( PRV_Cron::is_scheduled() );
}
}