Skip to content

Commit e84ed1e

Browse files
authored
Merge pull request #712 from ProgressPlanner/ari/tests-2025-11-17
Add phpunit tests
2 parents efe83af + 442c574 commit e84ed1e

27 files changed

Lines changed: 4085 additions & 23 deletions

.github/workflows/code-coverage.yml

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ name: Code Coverage
22

33
on:
44
pull_request:
5-
branches:
6-
- develop
7-
- main
85
push:
96
branches:
107
- develop
@@ -46,8 +43,10 @@ jobs:
4643
coverage: xdebug # Use Xdebug for coverage (PCOV was causing PHP to crash)
4744
tools: composer
4845

49-
- name: Install SVN
50-
run: sudo apt-get install subversion
46+
- name: Install SVN and XML tools
47+
run: |
48+
sudo apt-get update
49+
sudo apt-get install -y subversion libxml2-utils
5150
5251
- name: Install Composer dependencies
5352
uses: ramsey/composer-install@v2
@@ -74,13 +73,23 @@ jobs:
7473
# Run PHPUnit with coverage - allow test failures but ensure coverage is generated
7574
# test-class-security.php is excluded via phpunit.xml.dist to avoid output contamination
7675
set +e
76+
# Run PHPUnit and capture both test output and coverage text separately
7777
php -d memory_limit=512M -d max_execution_time=300 \
7878
vendor/bin/phpunit --configuration phpunit.xml.dist \
7979
--coverage-clover=coverage.xml \
80-
--coverage-text 2>&1 | tee phpunit-output.log
80+
--coverage-text --colors=never > phpunit-with-coverage.log 2>&1
8181
PHPUNIT_EXIT=$?
8282
set -e
8383
84+
# Extract test output (everything before coverage section) for debugging
85+
# Coverage section typically starts with a line like "Code Coverage Report:" or summary table
86+
# Extract everything up to (but not including) the coverage section
87+
awk '/Code Coverage Report:|^Summary|^ Classes:|^ Methods:|^ Lines:/{exit} {print}' phpunit-with-coverage.log > phpunit-output.log || cat phpunit-with-coverage.log > phpunit-output.log
88+
89+
# Extract coverage text output (the coverage section)
90+
# Coverage section starts with summary or "Code Coverage Report"
91+
awk '/Code Coverage Report:|^Summary|^ Classes:|^ Methods:|^ Lines:/{flag=1} flag' phpunit-with-coverage.log > current-coverage-full.txt || tail -200 phpunit-with-coverage.log > current-coverage-full.txt
92+
8493
echo "End time: $(date)"
8594
echo "Memory after: $(free -h | grep Mem)"
8695
echo "=== Debug: PHPUnit exit code: $PHPUNIT_EXIT ==="
@@ -117,14 +126,36 @@ jobs:
117126
- name: Generate coverage report summary
118127
id: coverage
119128
run: |
120-
# Generate coverage text report and save it
121-
vendor/bin/phpunit --coverage-text --colors=never > current-coverage-full.txt 2>&1 || true
129+
# Extract overall coverage from coverage.xml (Clover format)
130+
# This avoids running PHPUnit twice - we already have coverage.xml from the first run
131+
if [ -f coverage.xml ]; then
132+
# Extract metrics from Clover XML using xmllint
133+
# Fallback to Python if xmllint fails
134+
STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('statements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
135+
COVERED_STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('coveredstatements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
136+
137+
# Calculate coverage percentage
138+
if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED_STATEMENTS" ]; then
139+
COVERAGE=$(echo "scale=2; ($COVERED_STATEMENTS * 100) / $STATEMENTS" | bc)
140+
else
141+
COVERAGE="0"
142+
fi
143+
144+
echo "current_coverage=$COVERAGE" >> $GITHUB_OUTPUT
145+
echo "Current code coverage: $COVERAGE% (from coverage.xml)"
146+
echo "Statements: $COVERED_STATEMENTS / $STATEMENTS"
147+
else
148+
echo "ERROR: coverage.xml not found!"
149+
echo "current_coverage=0" >> $GITHUB_OUTPUT
150+
exit 1
151+
fi
122152
123-
# Extract overall coverage from the text report (the summary line, not per-class lines)
124-
# Look for the line that starts with " Lines:" (two spaces at the start)
125-
COVERAGE=$(grep "^ Lines:" current-coverage-full.txt | tail -1 | awk '{print $2}' | sed 's/%//' || echo "0")
126-
echo "current_coverage=$COVERAGE" >> $GITHUB_OUTPUT
127-
echo "Current code coverage: $COVERAGE%"
153+
# Coverage text output was already extracted from phpunit-with-coverage.log in the previous step
154+
# If extraction failed, try to generate it again as fallback
155+
if [ ! -s current-coverage-full.txt ]; then
156+
echo "Warning: Could not extract coverage text from phpunit-with-coverage.log, generating separately..."
157+
vendor/bin/phpunit --configuration phpunit.xml.dist --coverage-text --colors=never > current-coverage-full.txt 2>&1 || true
158+
fi
128159
129160
# Save detailed per-file coverage for later comparison
130161
# PHPUnit outputs class name on one line, stats on the next line
@@ -162,12 +193,34 @@ jobs:
162193
if: github.event_name == 'pull_request'
163194
id: base_coverage
164195
run: |
165-
# Generate coverage for base branch
166-
vendor/bin/phpunit --coverage-text --colors=never > base-coverage-full.txt 2>&1 || true
167-
# Extract overall coverage (summary line starting with " Lines:")
168-
BASE_COVERAGE=$(grep "^ Lines:" base-coverage-full.txt | tail -1 | awk '{print $2}' | sed 's/%//' || echo "0")
169-
echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT
170-
echo "Base branch code coverage: $BASE_COVERAGE%"
196+
# Generate coverage for base branch (including coverage.xml)
197+
vendor/bin/phpunit --configuration phpunit.xml.dist \
198+
--coverage-clover=base-coverage.xml \
199+
--coverage-text --colors=never > base-coverage-full.txt 2>&1 || true
200+
201+
# Extract overall coverage from base-coverage.xml (Clover format)
202+
if [ -f base-coverage.xml ]; then
203+
# Extract metrics from Clover XML using xmllint
204+
# Fallback to Python if xmllint fails
205+
STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' base-coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('base-coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('statements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
206+
COVERED_STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' base-coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('base-coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('coveredstatements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
207+
208+
# Calculate coverage percentage
209+
if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED_STATEMENTS" ]; then
210+
BASE_COVERAGE=$(echo "scale=2; ($COVERED_STATEMENTS * 100) / $STATEMENTS" | bc)
211+
else
212+
BASE_COVERAGE="0"
213+
fi
214+
215+
echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT
216+
echo "Base branch code coverage: $BASE_COVERAGE% (from base-coverage.xml)"
217+
echo "Statements: $COVERED_STATEMENTS / $STATEMENTS"
218+
else
219+
# Fallback to text extraction if XML not available
220+
BASE_COVERAGE=$(grep "^ Lines:" base-coverage-full.txt | tail -1 | awk '{print $2}' | sed 's/%//' || echo "0")
221+
echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT
222+
echo "Base branch code coverage: $BASE_COVERAGE% (from text fallback)"
223+
fi
171224
172225
# Extract per-file coverage for comparison
173226
# PHPUnit outputs class name on one line, stats on the next line

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ playwright/.cache/
1212
auth.json
1313

1414
# Environment variables
15-
.env
15+
.env
16+
coverage/

phpcs.xml.dist

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@
1010
#############################################################################
1111
-->
1212

13+
<!-- Include all PHP files in the project. -->
1314
<file>.</file>
1415

16+
<!-- Explicitly include test directories to ensure they are checked. -->
17+
<!-- PHPCS <file> tags support directories and will recursively scan them. -->
18+
<file>tests/phpunit</file>
19+
1520
<!-- Exclude the Composer Vendor directory. -->
16-
<exclude-pattern>/vendor/*</exclude-pattern>
21+
<exclude-pattern>*/vendor/*</exclude-pattern>
1722

1823
<!-- Exclude the Node Modules directory. -->
19-
<exclude-pattern>/node_modules/*</exclude-pattern>
24+
<exclude-pattern>*/node_modules/*</exclude-pattern>
2025

2126
<!-- Exclude the Coverage output directory. -->
22-
<exclude-pattern>/coverage/*</exclude-pattern>
27+
<exclude-pattern>*/coverage/*</exclude-pattern>
2328

2429
<!-- Exclude Javascript & CSS files. -->
2530
<exclude-pattern>*.js</exclude-pattern>

tests/bootstrap.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ function _manually_load_plugin() {
4444

4545
// Load base provider test class.
4646
require_once __DIR__ . '/phpunit/class-task-provider-test-trait.php';
47+
48+
// Load integration test base class.
49+
require_once __DIR__ . '/phpunit/integration/class-integration-test-case.php';
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
/**
3+
* Base class for integration tests.
4+
*
5+
* @package Progress_Planner\Tests
6+
*/
7+
8+
namespace Progress_Planner\Tests;
9+
10+
/**
11+
* Base class for integration tests.
12+
*/
13+
abstract class Integration_Test_Case extends \WP_UnitTestCase {
14+
15+
/**
16+
* Create a test task recommendation.
17+
*
18+
* @param array $args Task arguments.
19+
* @return int Post ID.
20+
*/
21+
protected function create_test_task( $args = [] ) {
22+
$defaults = [
23+
'task_id' => 'test-task-' . \uniqid(),
24+
'post_title' => 'Test Task',
25+
'provider_id' => 'test-provider',
26+
'post_status' => 'publish',
27+
];
28+
29+
$data = \wp_parse_args( $args, $defaults );
30+
31+
return \progress_planner()->get_suggested_tasks_db()->add( $data );
32+
}
33+
34+
/**
35+
* Create a test user task.
36+
*
37+
* @param array $args Task arguments.
38+
* @return int Post ID.
39+
*/
40+
protected function create_test_user_task( $args = [] ) {
41+
$defaults = [
42+
'task_id' => 'user-task-' . \uniqid(),
43+
'post_title' => 'User Task',
44+
'provider_id' => 'user',
45+
'post_status' => 'publish',
46+
];
47+
48+
$data = \wp_parse_args( $args, $defaults );
49+
50+
return \progress_planner()->get_suggested_tasks_db()->add( $data );
51+
}
52+
53+
/**
54+
* Make a REST API request.
55+
*
56+
* @param string $endpoint Endpoint path.
57+
* @param string $method HTTP method.
58+
* @param array $params Request parameters.
59+
* @return \WP_REST_Response|\WP_Error
60+
*/
61+
protected function make_rest_request( $endpoint, $method = 'GET', $params = [] ) {
62+
$request = new \WP_REST_Request( $method, $endpoint );
63+
foreach ( $params as $key => $value ) {
64+
$request->set_param( $key, $value );
65+
}
66+
67+
$server = \rest_get_server();
68+
return $server->dispatch( $request );
69+
}
70+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
/**
3+
* Cache Integration Test
4+
*
5+
* @package Progress_Planner\Tests
6+
*/
7+
8+
namespace Progress_Planner\Tests;
9+
10+
/**
11+
* Cache integration test case.
12+
*/
13+
class Cache_Integration_Test extends Integration_Test_Case {
14+
15+
/**
16+
* Test cache operations with WordPress transients.
17+
*
18+
* @return void
19+
*/
20+
public function test_cache_operations_with_transients() {
21+
$cache = \progress_planner()->get_utils__cache();
22+
23+
// Set cache value.
24+
$cache->set( 'test-key', 'test-value', HOUR_IN_SECONDS );
25+
26+
// Verify transient exists in database.
27+
$transient_name = 'progress_planner_test-key';
28+
$transient = \get_transient( $transient_name );
29+
$this->assertEquals( 'test-value', $transient );
30+
31+
// Get cache value.
32+
$retrieved = $cache->get( 'test-key' );
33+
$this->assertEquals( 'test-value', $retrieved );
34+
35+
// Delete cache value.
36+
$cache->delete( 'test-key' );
37+
\wp_cache_flush(); // Flush WordPress cache.
38+
39+
$retrieved = $cache->get( 'test-key' );
40+
$this->assertFalse( $retrieved );
41+
}
42+
43+
/**
44+
* Test cache expiration works correctly.
45+
*
46+
* @return void
47+
*/
48+
public function test_cache_expiration() {
49+
$cache = \progress_planner()->get_utils__cache();
50+
51+
// Set cache with short expiration.
52+
$cache->set( 'expiring-key', 'expiring-value', 1 );
53+
54+
// Should exist immediately.
55+
$this->assertEquals( 'expiring-value', $cache->get( 'expiring-key' ) );
56+
57+
// Wait for expiration (in real scenario, but in tests we can verify the transient timeout is set).
58+
$transient_name = 'progress_planner_expiring-key';
59+
$timeout = \get_option( '_transient_timeout_' . $transient_name );
60+
$this->assertNotFalse( $timeout );
61+
$this->assertGreaterThan( \time(), $timeout );
62+
}
63+
64+
/**
65+
* Test cache prefix isolation.
66+
*
67+
* @return void
68+
*/
69+
public function test_cache_prefix_isolation() {
70+
$cache = \progress_planner()->get_utils__cache();
71+
72+
// Set cache with our prefix.
73+
$cache->set( 'our-key', 'our-value' );
74+
75+
// Set transient without our prefix.
76+
\set_transient( 'other_transient', 'other-value', HOUR_IN_SECONDS );
77+
78+
// Delete all our cache.
79+
$cache->delete_all();
80+
\wp_cache_flush();
81+
82+
// Our cache should be gone.
83+
$this->assertFalse( $cache->get( 'our-key' ) );
84+
85+
// Other transient should still exist.
86+
$this->assertEquals( 'other-value', \get_transient( 'other_transient' ) );
87+
}
88+
89+
/**
90+
* Test cache delete_all removes all prefixed transients.
91+
*
92+
* @return void
93+
*/
94+
public function test_cache_delete_all() {
95+
$cache = \progress_planner()->get_utils__cache();
96+
97+
// Set multiple cache values.
98+
$cache->set( 'key-1', 'value-1' );
99+
$cache->set( 'key-2', 'value-2' );
100+
$cache->set( 'key-3', 'value-3' );
101+
102+
// Verify they exist.
103+
$this->assertEquals( 'value-1', $cache->get( 'key-1' ) );
104+
$this->assertEquals( 'value-2', $cache->get( 'key-2' ) );
105+
$this->assertEquals( 'value-3', $cache->get( 'key-3' ) );
106+
107+
// Delete all.
108+
$cache->delete_all();
109+
\wp_cache_flush();
110+
111+
// Verify they're all gone.
112+
$this->assertFalse( $cache->get( 'key-1' ) );
113+
$this->assertFalse( $cache->get( 'key-2' ) );
114+
$this->assertFalse( $cache->get( 'key-3' ) );
115+
}
116+
}

0 commit comments

Comments
 (0)