Skip to content

Commit 55f8b0d

Browse files
committed
test(mcp): add middleware pipeline tests
1 parent 83bb788 commit 55f8b0d

9 files changed

Lines changed: 869 additions & 0 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
namespace Saltus\WP\Framework\Tests\MCP\Middleware;
3+
4+
use PHPUnit\Framework\TestCase;
5+
use Saltus\WP\Framework\MCP\Audit\AuditLogger;
6+
use Saltus\WP\Framework\MCP\Middleware\AuditMiddleware;
7+
use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline;
8+
use Saltus\WP\Framework\MCP\Middleware\RequestContext;
9+
10+
require_once dirname( __DIR__, 2 ) . '/Rest/functions.php';
11+
12+
/**
13+
* @covers \Saltus\WP\Framework\MCP\Middleware\AuditMiddleware
14+
*/
15+
class AuditMiddlewareTest extends TestCase {
16+
17+
protected function setUp(): void {
18+
global $wpdb;
19+
$wpdb->inserts = [];
20+
}
21+
22+
public function testRecordsSuccessAudit(): void {
23+
global $wpdb;
24+
25+
$pipeline = new MiddlewarePipeline();
26+
$pipeline->add( new AuditMiddleware( new AuditLogger() ) );
27+
28+
$context = new RequestContext();
29+
$context->set_tool_metadata( [ 'name' => 'list_models' ] );
30+
$context->set_args( [ 'type' => 'post_types' ] );
31+
32+
$pipeline->execute( $context, function () {
33+
return new \WP_REST_Response( [ 'ok' => true ] );
34+
} );
35+
36+
$this->assertNotEmpty( $wpdb->inserts );
37+
$this->assertSame( 'success', $wpdb->inserts[0]['data']['status'] );
38+
$this->assertStringContainsString( 'list_models', $wpdb->inserts[0]['data']['ability'] );
39+
}
40+
41+
public function testRecordsErrorAudit(): void {
42+
global $wpdb;
43+
44+
$pipeline = new MiddlewarePipeline();
45+
$pipeline->add( new AuditMiddleware( new AuditLogger() ) );
46+
47+
$context = new RequestContext();
48+
$context->set_tool_metadata( [ 'name' => 'failing_tool' ] );
49+
50+
$pipeline->execute( $context, function () {
51+
return new \WP_Error( 'some_error', 'Something failed' );
52+
} );
53+
54+
$this->assertNotEmpty( $wpdb->inserts );
55+
$this->assertSame( 'error', $wpdb->inserts[0]['data']['status'] );
56+
$this->assertSame( 'some_error', $wpdb->inserts[0]['data']['error_code'] );
57+
}
58+
59+
public function testRecordsRouteBasedAudit(): void {
60+
global $wpdb;
61+
62+
$pipeline = new MiddlewarePipeline();
63+
$pipeline->add( new AuditMiddleware( new AuditLogger() ) );
64+
65+
$context = new RequestContext();
66+
$request = new \WP_REST_Request( 'GET', '/saltus-framework/v1/models' );
67+
$context->set_rest_request( $request );
68+
$context->set_route( '/saltus-framework/v1/models' );
69+
70+
$pipeline->execute( $context, function () {
71+
return new \WP_REST_Response( [ 'ok' => true ] );
72+
} );
73+
74+
$this->assertNotEmpty( $wpdb->inserts );
75+
$this->assertStringContainsString( 'rest:', $wpdb->inserts[0]['data']['ability'] );
76+
}
77+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
namespace Saltus\WP\Framework\Tests\MCP\Middleware;
3+
4+
use PHPUnit\Framework\TestCase;
5+
use Saltus\WP\Framework\MCP\Cache\TransientCache;
6+
use Saltus\WP\Framework\MCP\Middleware\CacheMiddleware;
7+
use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline;
8+
use Saltus\WP\Framework\MCP\Middleware\RequestContext;
9+
10+
require_once dirname( __DIR__, 2 ) . '/Rest/functions.php';
11+
12+
/**
13+
* @covers \Saltus\WP\Framework\MCP\Middleware\CacheMiddleware
14+
*/
15+
class CacheMiddlewareTest extends TestCase {
16+
17+
protected function setUp(): void {
18+
global $wp_transients;
19+
$wp_transients = [];
20+
}
21+
22+
public function testCachesGetResponse(): void {
23+
$cache = new TransientCache();
24+
$pipeline = new MiddlewarePipeline();
25+
$pipeline->add( new CacheMiddleware( $cache ) );
26+
$callCount = 0;
27+
28+
$context = new RequestContext();
29+
$context->set_args( [ 'type' => 'post_types' ] );
30+
$context->set_tool_metadata( [ 'name' => 'list_models' ] );
31+
$context->set_attribute( 'cache_ttl', 300 );
32+
33+
$result1 = $pipeline->execute( $context, function () use ( &$callCount ) {
34+
++$callCount;
35+
return new \WP_REST_Response( [ 'data' => 'fresh' ] );
36+
} );
37+
38+
$this->assertInstanceOf( \WP_REST_Response::class, $result1 );
39+
$this->assertSame( 1, $callCount );
40+
41+
$result2 = $pipeline->execute( $context, function () use ( &$callCount ) {
42+
++$callCount;
43+
return new \WP_REST_Response( [ 'data' => 'fresh' ] );
44+
} );
45+
46+
$this->assertInstanceOf( \WP_REST_Response::class, $result2 );
47+
$this->assertSame( 1, $callCount, 'Dispatch should not be called again for cached response' );
48+
}
49+
50+
public function testBypassesCacheForNonGet(): void {
51+
$cache = new TransientCache();
52+
$pipeline = new MiddlewarePipeline();
53+
$pipeline->add( new CacheMiddleware( $cache ) );
54+
$callCount = 0;
55+
56+
$context = new RequestContext();
57+
$request = new \WP_REST_Request( 'POST', '/saltus-framework/v1/settings/movie' );
58+
$context->set_rest_request( $request );
59+
$context->set_attribute( 'cache_ttl', 300 );
60+
61+
$result1 = $pipeline->execute( $context, function () use ( &$callCount ) {
62+
++$callCount;
63+
return new \WP_REST_Response( [ 'updated' => true ] );
64+
} );
65+
66+
$this->assertSame( 1, $callCount );
67+
68+
$result2 = $pipeline->execute( $context, function () use ( &$callCount ) {
69+
++$callCount;
70+
return new \WP_REST_Response( [ 'updated' => true ] );
71+
} );
72+
73+
$this->assertSame( 2, $callCount, 'POST requests should not be cached' );
74+
}
75+
76+
public function testPassesErrorResponsesWithoutCaching(): void {
77+
$cache = new TransientCache();
78+
$pipeline = new MiddlewarePipeline();
79+
$pipeline->add( new CacheMiddleware( $cache ) );
80+
81+
$context = new RequestContext();
82+
$context->set_args( [ 'bad' => 'args' ] );
83+
$context->set_tool_metadata( [ 'name' => 'bad_tool' ] );
84+
85+
$result = $pipeline->execute( $context, function () {
86+
return new \WP_Error( 'error', 'Something went wrong' );
87+
} );
88+
89+
$this->assertInstanceOf( \WP_Error::class, $result );
90+
91+
$cache_key = 'saltus_mcp_' . hash( 'sha256', wp_json_encode( [
92+
'tool' => 'bad_tool',
93+
'args' => [ 'bad' => 'args' ],
94+
'user' => 1,
95+
'locale' => 'en_US',
96+
] ) );
97+
98+
$this->assertNull( $cache->get( $cache_key ) );
99+
}
100+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
namespace Saltus\WP\Framework\Tests\MCP\Middleware;
3+
4+
use PHPUnit\Framework\TestCase;
5+
use Saltus\WP\Framework\MCP\Error\ErrorResponse;
6+
7+
require_once dirname( __DIR__, 2 ) . '/Rest/functions.php';
8+
9+
/**
10+
* @covers \Saltus\WP\Framework\MCP\Error\ErrorResponse
11+
*/
12+
class ErrorResponseTest extends TestCase {
13+
14+
public function testForbidden(): void {
15+
$error = ErrorResponse::forbidden( 'edit_posts', 'Assign edit_posts to your user.' );
16+
17+
$this->assertInstanceOf( \WP_Error::class, $error );
18+
$this->assertSame( 'rest_forbidden', $error->get_error_code() );
19+
$this->assertSame( 403, $error->get_error_data()['status'] ?? null );
20+
$this->assertSame( 'Assign edit_posts to your user.', $error->get_error_data()['hint'] ?? '' );
21+
}
22+
23+
public function testForbiddenMinimal(): void {
24+
$error = ErrorResponse::forbidden();
25+
26+
$this->assertSame( 'rest_forbidden', $error->get_error_code() );
27+
$this->assertArrayNotHasKey( 'hint', $error->get_error_data() );
28+
}
29+
30+
public function testNotFound(): void {
31+
$error = ErrorResponse::not_found( 'model', 'Model not registered.' );
32+
33+
$this->assertInstanceOf( \WP_Error::class, $error );
34+
$this->assertSame( 'model_not_found', $error->get_error_code() );
35+
$this->assertSame( 404, $error->get_error_data()['status'] ?? null );
36+
$this->assertSame( 'Model not registered.', $error->get_error_data()['hint'] ?? '' );
37+
}
38+
39+
public function testNotFoundMinimal(): void {
40+
$error = ErrorResponse::not_found();
41+
42+
$this->assertSame( 'not_found', $error->get_error_code() );
43+
}
44+
45+
public function testInvalid(): void {
46+
$error = ErrorResponse::invalid( 'post_type', 'Must be a string', 'Provide a valid post type slug.' );
47+
48+
$this->assertInstanceOf( \WP_Error::class, $error );
49+
$this->assertSame( 'rest_invalid_param', $error->get_error_code() );
50+
$this->assertSame( 400, $error->get_error_data()['status'] ?? null );
51+
$this->assertSame( 'post_type', $error->get_error_data()['field'] ?? '' );
52+
$this->assertSame( 'Must be a string', $error->get_error_data()['reason'] ?? '' );
53+
}
54+
55+
public function testRateLimited(): void {
56+
$error = ErrorResponse::rate_limited( 30 );
57+
58+
$this->assertInstanceOf( \WP_Error::class, $error );
59+
$this->assertSame( 'rate_limited', $error->get_error_code() );
60+
$this->assertSame( 429, $error->get_error_data()['status'] ?? null );
61+
$this->assertSame( 30, $error->get_error_data()['retry_after'] ?? 0 );
62+
}
63+
64+
public function testInternalError(): void {
65+
$error = ErrorResponse::internal_error( 'Something broke.', 'Contact support.' );
66+
67+
$this->assertInstanceOf( \WP_Error::class, $error );
68+
$this->assertSame( 'rest_internal_error', $error->get_error_code() );
69+
$this->assertSame( 500, $error->get_error_data()['status'] ?? null );
70+
$this->assertSame( 'Contact support.', $error->get_error_data()['hint'] ?? '' );
71+
}
72+
73+
public function testDispatchError(): void {
74+
$upstream = new \WP_Error( 'db_error', 'Database query failed.', [ 'status' => 503 ] );
75+
$error = ErrorResponse::dispatch_error( $upstream );
76+
77+
$this->assertInstanceOf( \WP_Error::class, $error );
78+
$this->assertSame( 'db_error', $error->get_error_code() );
79+
$this->assertSame( 503, $error->get_error_data()['status'] ?? null );
80+
}
81+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
namespace Saltus\WP\Framework\Tests\MCP\Middleware;
3+
4+
use PHPUnit\Framework\TestCase;
5+
use Saltus\WP\Framework\MCP\Middleware\MiddlewareInterface;
6+
use Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline;
7+
use Saltus\WP\Framework\MCP\Middleware\RequestContext;
8+
9+
require_once dirname( __DIR__, 2 ) . '/Rest/functions.php';
10+
11+
/**
12+
* @covers \Saltus\WP\Framework\MCP\Middleware\MiddlewarePipeline
13+
*/
14+
class MiddlewarePipelineTest extends TestCase {
15+
16+
public function testExecutesSingleStage(): void {
17+
$pipeline = new MiddlewarePipeline();
18+
$pipeline->add( new class implements MiddlewareInterface {
19+
public function handle( RequestContext $context, callable $next ) {
20+
return new \WP_REST_Response( [ 'handled' => true ] );
21+
}
22+
} );
23+
24+
$context = new RequestContext();
25+
$result = $pipeline->execute( $context, function () {
26+
return new \WP_REST_Response( [ 'dispatched' => true ] );
27+
} );
28+
29+
$this->assertInstanceOf( \WP_REST_Response::class, $result );
30+
$data = $result->get_data();
31+
$this->assertTrue( $data['handled'] );
32+
}
33+
34+
public function testExecutesMultipleStagesInOrder(): void {
35+
$pipeline = new MiddlewarePipeline();
36+
$GLOBALS['_test_log'] = [];
37+
38+
$pipeline->add( new class implements MiddlewareInterface {
39+
public function handle( RequestContext $context, callable $next ) {
40+
$GLOBALS['_test_log'][] = 'first';
41+
return $next( $context );
42+
}
43+
} );
44+
45+
$pipeline->add( new class implements MiddlewareInterface {
46+
public function handle( RequestContext $context, callable $next ) {
47+
$GLOBALS['_test_log'][] = 'second';
48+
return $next( $context );
49+
}
50+
} );
51+
52+
$pipeline->add( new class implements MiddlewareInterface {
53+
public function handle( RequestContext $context, callable $next ) {
54+
$GLOBALS['_test_log'][] = 'third';
55+
return $next( $context );
56+
}
57+
} );
58+
59+
$context = new RequestContext();
60+
$pipeline->execute( $context, function () {
61+
$GLOBALS['_test_log'][] = 'dispatch';
62+
return new \WP_REST_Response( [ 'ok' => true ] );
63+
} );
64+
65+
$this->assertSame( [ 'first', 'second', 'third', 'dispatch' ], $GLOBALS['_test_log'] );
66+
unset( $GLOBALS['_test_log'] );
67+
}
68+
69+
public function testShortCircuitsOnError(): void {
70+
$pipeline = new MiddlewarePipeline();
71+
$GLOBALS['_test_log'] = [];
72+
73+
$pipeline->add( new class implements MiddlewareInterface {
74+
public function handle( RequestContext $context, callable $next ) {
75+
$GLOBALS['_test_log'][] = 'pass';
76+
return $next( $context );
77+
}
78+
} );
79+
80+
$pipeline->add( new class implements MiddlewareInterface {
81+
public function handle( RequestContext $context, callable $next ) {
82+
$GLOBALS['_test_log'][] = 'block';
83+
return new \WP_Error( 'blocked', 'Blocked' );
84+
}
85+
} );
86+
87+
$pipeline->add( new class implements MiddlewareInterface {
88+
public function handle( RequestContext $context, callable $next ) {
89+
$GLOBALS['_test_log'][] = 'should_not_reach';
90+
return $next( $context );
91+
}
92+
} );
93+
94+
$context = new RequestContext();
95+
$result = $pipeline->execute( $context, function () {
96+
$GLOBALS['_test_log'][] = 'dispatch';
97+
return new \WP_REST_Response( [ 'ok' => true ] );
98+
} );
99+
100+
$this->assertInstanceOf( \WP_Error::class, $result );
101+
$this->assertSame( 'blocked', $result->get_error_code() );
102+
$this->assertSame( [ 'pass', 'block' ], $GLOBALS['_test_log'] );
103+
unset( $GLOBALS['_test_log'] );
104+
}
105+
106+
public function testExecutesWithNoStages(): void {
107+
$pipeline = new MiddlewarePipeline();
108+
109+
$context = new RequestContext();
110+
$result = $pipeline->execute( $context, function () {
111+
return new \WP_REST_Response( [ 'direct' => true ] );
112+
} );
113+
114+
$this->assertInstanceOf( \WP_REST_Response::class, $result );
115+
$data = $result->get_data();
116+
$this->assertTrue( $data['direct'] );
117+
}
118+
119+
public function testContextIsMutableThroughChain(): void {
120+
$pipeline = new MiddlewarePipeline();
121+
122+
$pipeline->add( new class implements MiddlewareInterface {
123+
public function handle( RequestContext $context, callable $next ) {
124+
$context->set_attribute( 'trace', 'added' );
125+
return $next( $context );
126+
}
127+
} );
128+
129+
$context = new RequestContext();
130+
$pipeline->execute( $context, function ( RequestContext $ctx ) {
131+
$this->assertSame( 'added', $ctx->get_attribute( 'trace' ) );
132+
return new \WP_REST_Response( [ 'ok' => true ] );
133+
} );
134+
}
135+
}

0 commit comments

Comments
 (0)