Expand redMCP safe issue operations and HTTP handling
This commit is contained in:
Executable
+550
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use RedMCP\RedmineClient;
|
||||
use RedMCP\McpDispatcher;
|
||||
use Redmine\Api;
|
||||
use Redmine\Client\Client;
|
||||
use Redmine\Http\HttpClient;
|
||||
use Redmine\Http\HttpFactory;
|
||||
use Redmine\Http\Request;
|
||||
use Redmine\Http\Response;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
final class RecordingClient implements Client, HttpClient
|
||||
{
|
||||
/** @var array<int,array{method:string,path:string,content_type:string,content:string}> */
|
||||
public array $requests = [];
|
||||
|
||||
/** @var array<int,Response> */
|
||||
private array $responses = [];
|
||||
|
||||
public function queueJson(array $payload, int $status = 200): void
|
||||
{
|
||||
$encoded = json_encode($payload);
|
||||
if ($encoded === false) {
|
||||
throw new RuntimeException('Could not encode fixture JSON.');
|
||||
}
|
||||
|
||||
$this->responses[] = HttpFactory::makeResponse($status, 'application/json', $encoded);
|
||||
}
|
||||
|
||||
public function queueBinary(string $content, string $contentType = 'application/octet-stream', int $status = 200): void
|
||||
{
|
||||
$this->responses[] = HttpFactory::makeResponse($status, $contentType, $content);
|
||||
}
|
||||
|
||||
public function request(Request $request): Response
|
||||
{
|
||||
$this->requests[] = [
|
||||
'method' => $request->getMethod(),
|
||||
'path' => $request->getPath(),
|
||||
'content_type' => $request->getContentType(),
|
||||
'content' => $request->getContent(),
|
||||
];
|
||||
|
||||
return array_shift($this->responses) ?? HttpFactory::makeResponse(200, 'application/json', '{}');
|
||||
}
|
||||
|
||||
public function getApi(string $name): Api
|
||||
{
|
||||
throw new RuntimeException('Unexpected vendor API call for ' . $name);
|
||||
}
|
||||
|
||||
public function startImpersonateUser(string $username): void {}
|
||||
public function stopImpersonateUser(): void {}
|
||||
public function requestGet(string $path): bool { return false; }
|
||||
public function requestPost(string $path, string $body): bool { return false; }
|
||||
public function requestPut(string $path, string $body): bool { return false; }
|
||||
public function requestDelete(string $path): bool { return false; }
|
||||
public function getLastResponseStatusCode(): int { return 0; }
|
||||
public function getLastResponseContentType(): string { return ''; }
|
||||
public function getLastResponseBody(): string { return ''; }
|
||||
}
|
||||
|
||||
final class RedmineStructureTest
|
||||
{
|
||||
private int $assertions = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->testCreateIssuePreservesStructureFields();
|
||||
$this->testUpdateIssuePreservesParentAndUploads();
|
||||
$this->testMcpCreateIssueAcceptsFlatIssueFields();
|
||||
$this->testMcpUpdateIssueAcceptsFlatIssueFields();
|
||||
$this->testMcpFindProjectRecommendsExactIdentifier();
|
||||
$this->testMcpFindProjectRecommendsExactName();
|
||||
$this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended();
|
||||
$this->testCreateRelationDefaultsToRelatesAndRequiresTarget();
|
||||
$this->testAttachmentUploadSupportsPathAndBase64();
|
||||
$this->testAttachmentUploadAcceptsPdfDataUrl();
|
||||
$this->testAttachmentUploadAcceptsFileEnvelope();
|
||||
$this->testDownloadPathValidationRejectsUnsafePaths();
|
||||
$this->testDownloadAttachmentWritesSafePathAndLimitsBase64();
|
||||
$this->testMcpToolListExposesStructureToolsWithoutIssueDelete();
|
||||
|
||||
fwrite(STDOUT, "OK {$this->assertions} assertions\n");
|
||||
}
|
||||
|
||||
private function testCreateIssuePreservesStructureFields(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['issue' => ['id' => 123]], 201);
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$result = $client->createIssue([
|
||||
'project_id' => 'fud-nohelpdesk',
|
||||
'subject' => 'Child issue',
|
||||
'parent_issue_id' => 99,
|
||||
'category_id' => 4,
|
||||
'uploads' => [
|
||||
['token' => 'tok-1', 'filename' => 'note.txt', 'content_type' => 'text/plain'],
|
||||
],
|
||||
]);
|
||||
|
||||
$request = $http->requests[0];
|
||||
$payload = $this->json($request['content']);
|
||||
$this->assertSame('POST', $request['method'], 'create issue uses POST');
|
||||
$this->assertSame('/issues.json', $request['path'], 'create issue uses raw JSON endpoint');
|
||||
$this->assertSame(99, $payload['issue']['parent_issue_id'], 'create issue preserves parent_issue_id');
|
||||
$this->assertSame(4, $payload['issue']['category_id'], 'create issue preserves category_id');
|
||||
$this->assertSame('tok-1', $payload['issue']['uploads'][0]['token'], 'create issue preserves upload tokens');
|
||||
$this->assertSame(123, $result['id'], 'create issue unwraps issue response');
|
||||
}
|
||||
|
||||
private function testUpdateIssuePreservesParentAndUploads(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson([], 204);
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$ok = $client->updateIssue(123, [
|
||||
'parent_id' => 99,
|
||||
'category_id' => 4,
|
||||
'uploads' => [
|
||||
['token' => 'tok-2', 'filename' => 'followup.txt'],
|
||||
],
|
||||
]);
|
||||
|
||||
$request = $http->requests[0];
|
||||
$payload = $this->json($request['content']);
|
||||
$this->assertSame(true, $ok, 'update issue returns true on 204');
|
||||
$this->assertSame('PUT', $request['method'], 'update issue uses PUT');
|
||||
$this->assertSame('/issues/123.json', $request['path'], 'update issue uses raw JSON endpoint');
|
||||
$this->assertSame(99, $payload['issue']['parent_id'], 'update issue preserves parent_id');
|
||||
$this->assertSame('tok-2', $payload['issue']['uploads'][0]['token'], 'update issue preserves upload tokens');
|
||||
}
|
||||
|
||||
private function testMcpCreateIssueAcceptsFlatIssueFields(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['issue' => ['id' => 321]], 201);
|
||||
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||
|
||||
$response = $dispatcher->handleMessage([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => 'redmine_create_issue',
|
||||
'arguments' => [
|
||||
'project_id' => 'quality-tracker',
|
||||
'subject' => 'Front warehouse deadbolt key gets stuck in lock',
|
||||
'description' => "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot",
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (!is_array($response) || isset($response['error'])) {
|
||||
throw new RuntimeException('Expected flat create issue call to succeed: ' . json_encode($response));
|
||||
}
|
||||
$payload = $this->json($http->requests[0]['content']);
|
||||
$this->assertSame('quality-tracker', $payload['issue']['project_id'], 'flat MCP create preserves project_id');
|
||||
$this->assertSame('Front warehouse deadbolt key gets stuck in lock', $payload['issue']['subject'], 'flat MCP create preserves subject');
|
||||
$this->assertStringContains('~HermesBot', $payload['issue']['description'], 'flat MCP create preserves multiline description');
|
||||
}
|
||||
|
||||
private function testMcpUpdateIssueAcceptsFlatIssueFields(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson([], 204);
|
||||
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||
|
||||
$response = $dispatcher->handleMessage([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => 'redmine_update_issue',
|
||||
'arguments' => [
|
||||
'issue_id' => 321,
|
||||
'notes' => 'Locksmith has been scheduled.',
|
||||
'status_id' => 2,
|
||||
'options' => ['send_helpdesk_email' => false],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (!is_array($response) || isset($response['error'])) {
|
||||
throw new RuntimeException('Expected flat update issue call to succeed: ' . json_encode($response));
|
||||
}
|
||||
$payload = $this->json($http->requests[0]['content']);
|
||||
$this->assertSame('/issues/321.json', $http->requests[0]['path'], 'flat MCP update uses requested issue id');
|
||||
$this->assertSame('Locksmith has been scheduled.', $payload['issue']['notes'], 'flat MCP update preserves notes');
|
||||
$this->assertSame(2, $payload['issue']['status_id'], 'flat MCP update preserves status_id');
|
||||
$this->assertSame(false, isset($payload['issue']['options']), 'flat MCP update does not forward options as issue field');
|
||||
}
|
||||
|
||||
private function testMcpFindProjectRecommendsExactIdentifier(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||
|
||||
$result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality-tracker']);
|
||||
|
||||
$this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact identifier produces recommendation');
|
||||
$this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'exact identifier match is first');
|
||||
$this->assertSame('exact_identifier', $result['matches'][0]['match_reason'], 'exact identifier reason is reported');
|
||||
$this->assertSame('quality-tracker', $result['matches'][0]['project_id_to_use'], 'identifier is preferred project_id_to_use');
|
||||
}
|
||||
|
||||
private function testMcpFindProjectRecommendsExactName(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||
|
||||
$result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'Quality Tracker']);
|
||||
|
||||
$this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact project name produces recommendation');
|
||||
$this->assertSame('Quality Tracker', $result['matches'][0]['name'], 'exact name match is first');
|
||||
$this->assertSame('exact_name', $result['matches'][0]['match_reason'], 'exact name reason is reported');
|
||||
}
|
||||
|
||||
private function testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['projects' => $this->projectFixtures()]);
|
||||
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
||||
|
||||
$result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality']);
|
||||
|
||||
$this->assertSame(null, $result['recommended_project_id'], 'ambiguous project query has no recommendation');
|
||||
$this->assertSame(2, count($result['matches']), 'ambiguous project query returns both matches');
|
||||
$this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'first ambiguous match is ranked deterministically');
|
||||
$this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned');
|
||||
}
|
||||
|
||||
private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['relation' => ['id' => 55]], 201);
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$result = $client->createIssueRelation(10, ['issue_to_id' => 11]);
|
||||
$request = $http->requests[0];
|
||||
$payload = $this->json($request['content']);
|
||||
|
||||
$this->assertSame('/issues/10/relations.json', $request['path'], 'relation create uses issue relations endpoint');
|
||||
$this->assertSame(11, $payload['relation']['issue_to_id'], 'relation create sends issue_to_id');
|
||||
$this->assertSame('relates', $payload['relation']['relation_type'], 'relation create defaults to relates');
|
||||
$this->assertSame(55, $result['id'], 'relation create unwraps relation response');
|
||||
|
||||
$this->assertThrows(
|
||||
static fn() => $client->createIssueRelation(10, []),
|
||||
'issue_to_id',
|
||||
'relation create requires issue_to_id'
|
||||
);
|
||||
}
|
||||
|
||||
private function testAttachmentUploadSupportsPathAndBase64(): void
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/redmcp-upload-test.txt';
|
||||
file_put_contents($path, 'from path');
|
||||
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['upload' => ['token' => 'path-token']], 201);
|
||||
$http->queueJson(['upload' => ['token' => 'base64-token']], 201);
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$pathResult = $client->uploadAttachment(['path' => $path, 'content_type' => 'text/plain']);
|
||||
$base64Result = $client->uploadAttachment([
|
||||
'base64_content' => base64_encode('from base64'),
|
||||
'filename' => 'base64.txt',
|
||||
'content_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$this->assertSame('path-token', $pathResult['token'], 'path upload unwraps token');
|
||||
$this->assertStringContains('filename=redmcp-upload-test.txt', $http->requests[0]['path'], 'path upload uses basename as filename');
|
||||
$this->assertSame('from path', $http->requests[0]['content'], 'path upload sends file bytes');
|
||||
$this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'path upload sends bytes as Redmine upload stream');
|
||||
$this->assertSame('text/plain', $pathResult['content_type'], 'path upload preserves desired attachment content type metadata');
|
||||
$this->assertSame('base64-token', $base64Result['token'], 'base64 upload unwraps token');
|
||||
$this->assertStringContains('filename=base64.txt', $http->requests[1]['path'], 'base64 upload uses provided filename');
|
||||
$this->assertSame('from base64', $http->requests[1]['content'], 'base64 upload sends decoded bytes');
|
||||
}
|
||||
|
||||
private function testAttachmentUploadAcceptsPdfDataUrl(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['upload' => ['token' => 'pdf-token']], 201);
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$result = $client->uploadAttachment([
|
||||
'data_url' => 'data:application/pdf;base64,' . base64_encode('%PDF-1.4 raw pdf bytes'),
|
||||
]);
|
||||
|
||||
$this->assertSame('pdf-token', $result['token'], 'PDF data URL upload unwraps token');
|
||||
$this->assertStringContains('filename=attachment.pdf', $http->requests[0]['path'], 'PDF data URL derives a useful filename');
|
||||
$this->assertSame('%PDF-1.4 raw pdf bytes', $http->requests[0]['content'], 'PDF data URL upload sends decoded bytes');
|
||||
$this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'PDF data URL upload sends bytes as Redmine upload stream');
|
||||
$this->assertSame('application/pdf', $result['content_type'], 'PDF data URL preserves PDF content type metadata');
|
||||
}
|
||||
|
||||
private function testAttachmentUploadAcceptsFileEnvelope(): void
|
||||
{
|
||||
$http = new RecordingClient();
|
||||
$http->queueJson(['upload' => ['token' => 'file-token']], 201);
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$result = $client->uploadAttachment([
|
||||
'file' => [
|
||||
'name' => 'quote.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'data' => base64_encode('%PDF-1.7 envelope bytes'),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('file-token', $result['token'], 'file envelope upload unwraps token');
|
||||
$this->assertStringContains('filename=quote.pdf', $http->requests[0]['path'], 'file envelope uses provided name as filename');
|
||||
$this->assertSame('%PDF-1.7 envelope bytes', $http->requests[0]['content'], 'file envelope sends decoded bytes');
|
||||
$this->assertSame('application/pdf', $result['content_type'], 'file envelope preserves MIME type metadata');
|
||||
}
|
||||
|
||||
private function testDownloadPathValidationRejectsUnsafePaths(): void
|
||||
{
|
||||
$client = new RedmineClient(new RecordingClient());
|
||||
|
||||
$this->assertThrows(
|
||||
static fn() => $client->downloadAttachment(77, '/etc/redmcp-forbidden.txt'),
|
||||
'under /tmp or the repository tree',
|
||||
'download rejects paths outside safe roots'
|
||||
);
|
||||
}
|
||||
|
||||
private function testDownloadAttachmentWritesSafePathAndLimitsBase64(): void
|
||||
{
|
||||
$destination = sys_get_temp_dir() . '/redmcp-download-test.txt';
|
||||
if (file_exists($destination)) {
|
||||
unlink($destination);
|
||||
}
|
||||
|
||||
$http = new RecordingClient();
|
||||
$http->queueBinary('downloaded attachment', 'text/plain');
|
||||
$client = new RedmineClient($http);
|
||||
|
||||
$result = $client->downloadAttachment(77, $destination, true, 4);
|
||||
|
||||
$this->assertSame('/attachments/download/77', $http->requests[0]['path'], 'download uses Redmine attachment download endpoint');
|
||||
$this->assertSame('downloaded attachment', (string) file_get_contents($destination), 'download writes attachment bytes');
|
||||
$this->assertSame(21, $result['bytes'], 'download reports byte length');
|
||||
$this->assertSame(true, $result['base64_omitted'], 'download omits oversized base64 content');
|
||||
}
|
||||
|
||||
private function testMcpToolListExposesStructureToolsWithoutIssueDelete(): void
|
||||
{
|
||||
$dispatcher = new McpDispatcher(new RedmineClient(new RecordingClient()));
|
||||
$response = $dispatcher->handleMessage([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
]);
|
||||
if (!is_array($response)) {
|
||||
throw new RuntimeException('Expected tools/list response.');
|
||||
}
|
||||
|
||||
$names = array_map(
|
||||
static fn(array $tool): string => (string) $tool['name'],
|
||||
$response['result']['tools']
|
||||
);
|
||||
|
||||
foreach ([
|
||||
'redmine_list_issue_relations',
|
||||
'redmine_find_project',
|
||||
'redmine_get_issue_relation',
|
||||
'redmine_create_issue_relation',
|
||||
'redmine_remove_issue_relation',
|
||||
'redmine_list_issue_children',
|
||||
'redmine_set_issue_parent',
|
||||
'redmine_clear_issue_parent',
|
||||
'redmine_list_project_issue_categories',
|
||||
'redmine_get_issue_category',
|
||||
'redmine_create_issue_category',
|
||||
'redmine_update_issue_category',
|
||||
'redmine_get_attachment',
|
||||
'redmine_upload_attachment',
|
||||
'redmine_download_attachment',
|
||||
'redmine_update_attachment',
|
||||
] as $expectedTool) {
|
||||
$this->assertContains($expectedTool, $names, $expectedTool . ' is listed');
|
||||
}
|
||||
|
||||
$this->assertNotContains('redmine_delete_issue', $names, 'issue delete tool is not listed');
|
||||
|
||||
$uploadTool = null;
|
||||
foreach ($response['result']['tools'] as $tool) {
|
||||
if (($tool['name'] ?? '') === 'redmine_upload_attachment') {
|
||||
$uploadTool = $tool;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!is_array($uploadTool)) {
|
||||
throw new RuntimeException('Expected redmine_upload_attachment tool schema.');
|
||||
}
|
||||
$uploadProperties = array_keys($uploadTool['inputSchema']['properties']);
|
||||
$this->assertContains('data_url', $uploadProperties, 'upload tool advertises data_url input');
|
||||
$this->assertContains('file', $uploadProperties, 'upload tool advertises file envelope input');
|
||||
|
||||
$createIssueTool = null;
|
||||
foreach ($response['result']['tools'] as $tool) {
|
||||
if (($tool['name'] ?? '') === 'redmine_create_issue') {
|
||||
$createIssueTool = $tool;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!is_array($createIssueTool)) {
|
||||
throw new RuntimeException('Expected redmine_create_issue tool schema.');
|
||||
}
|
||||
$projectDescription = (string) $createIssueTool['inputSchema']['properties']['project_id']['description'];
|
||||
$this->assertStringContains('redmine_find_project', $projectDescription, 'project_id schema points agents to project resolver');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function projectFixtures(): array
|
||||
{
|
||||
return [
|
||||
['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker'],
|
||||
['id' => 79, 'identifier' => 'quality-archive', 'name' => 'Quality Archive'],
|
||||
['id' => 80, 'identifier' => 'warehouse', 'name' => 'Warehouse Operations'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function callToolJson(McpDispatcher $dispatcher, string $name, array $arguments): array
|
||||
{
|
||||
$response = $dispatcher->handleMessage([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $name,
|
||||
'arguments' => $arguments,
|
||||
],
|
||||
]);
|
||||
if (!is_array($response) || isset($response['error'])) {
|
||||
throw new RuntimeException('Expected MCP tool call to succeed: ' . json_encode($response));
|
||||
}
|
||||
$content = $response['result']['content'][0]['text'] ?? null;
|
||||
if (!is_string($content)) {
|
||||
throw new RuntimeException('Expected MCP tool text content.');
|
||||
}
|
||||
|
||||
return $this->json($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function json(string $content): array
|
||||
{
|
||||
$decoded = json_decode($content, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Invalid JSON: ' . $content);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $expected
|
||||
* @param mixed $actual
|
||||
*/
|
||||
private function assertSame($expected, $actual, string $message): void
|
||||
{
|
||||
$this->assertions++;
|
||||
if ($expected === $actual) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
private function assertStringContains(string $needle, string $haystack, string $message): void
|
||||
{
|
||||
$this->assertions++;
|
||||
if (strpos($haystack, $needle) !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $haystack
|
||||
*/
|
||||
private function assertContains(string $needle, array $haystack, string $message): void
|
||||
{
|
||||
$this->assertions++;
|
||||
if (in_array($needle, $haystack, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(STDERR, "FAIL: {$message}\nMissing: {$needle}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $haystack
|
||||
*/
|
||||
private function assertNotContains(string $needle, array $haystack, string $message): void
|
||||
{
|
||||
$this->assertions++;
|
||||
if (!in_array($needle, $haystack, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(STDERR, "FAIL: {$message}\nUnexpected: {$needle}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
private function assertThrows(callable $callback, string $expectedMessagePart, string $message): void
|
||||
{
|
||||
$this->assertions++;
|
||||
try {
|
||||
$callback();
|
||||
} catch (Throwable $exception) {
|
||||
if (strpos($exception->getMessage(), $expectedMessagePart) !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(STDERR, "FAIL: {$message}\nExpected exception containing: {$expectedMessagePart}\nActual: {$exception->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "FAIL: {$message}\nExpected exception was not thrown.\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
(new RedmineStructureTest())->run();
|
||||
Reference in New Issue
Block a user