From a177044482b4b81c96c6fd4a574335d3b78e4404 Mon Sep 17 00:00:00 2001 From: Leonardo Marino-Ramirez Date: Sun, 15 Mar 2026 14:33:42 -0400 Subject: [PATCH] Add OpenAI-compatible self-hosted vision backend - New provider: OpenAI-compatible endpoint (llama.cpp, Ollama, vLLM, LiteLLM, etc.) - New feature: optionally write AI description as photo comment - Admin UI: checkbox and textarea field types for plugin configuration - Fallback tag extraction when model returns free text instead of JSON - Backward-compatible: no changes to existing Imagga/Azure behavior - PHPUnit test suite: 22 tests, 75 assertions - Live-tested against Qwen3-VL-30B-A3B on RTX 5090 with real Piwigo DB --- .gitignore | 3 + .phpunit.result.cache | 1 + README.md | 44 ++- admin.php | 39 ++- api_classes/OpenAICompatible.php | 174 +++++++++++ composer.json | 14 + include/api_types.php | 14 +- include/functions.inc.php | 3 +- language/en_UK/plugin.lang.php | 7 + phpunit.xml | 13 + template/admin.tpl | 18 +- tests/OpenAICompatibleTest.php | 481 ++++++++++++++++++++++++++++++ tests/bootstrap.php | 166 +++++++++++ tests/fixtures/test_image.jpg | 1 + tests/live_test.php | 485 +++++++++++++++++++++++++++++++ tests/mock_api_server.php | 33 +++ 16 files changed, 1472 insertions(+), 24 deletions(-) create mode 100644 .gitignore create mode 100644 .phpunit.result.cache create mode 100644 api_classes/OpenAICompatible.php create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 tests/OpenAICompatibleTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/fixtures/test_image.jpg create mode 100644 tests/live_test.php create mode 100644 tests/mock_api_server.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aebc5ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +tests/fixtures/current_response.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..e373aa6 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":2,"defects":{"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDisabled":5,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty":5},"times":{"OpenAICompatibleTest::testGetInfoReturnsRequiredKeys":0.001,"OpenAICompatibleTest::testGetConfParamsReturnsAllFields":0,"OpenAICompatibleTest::testGetConfFieldTypes":0,"OpenAICompatibleTest::testGenerateTagsMissingEndpointThrowsException":0,"OpenAICompatibleTest::testGenerateTagsMissingModelThrowsException":0,"OpenAICompatibleTest::testGenerateTagsThrowsWhenImageFileUnreadable":0.001,"OpenAICompatibleTest::testGenerateTagsWithValidJsonResponse":0.001,"OpenAICompatibleTest::testGenerateTagsWithMarkdownFencedJson":0,"OpenAICompatibleTest::testGenerateTagsWithUnlabelledMarkdownFence":0,"OpenAICompatibleTest::testGenerateTagsReturnsEmptyArrayWhenTagsKeyMissing":0,"OpenAICompatibleTest::testGenerateTagsRespectsLimit":0,"OpenAICompatibleTest::testGenerateTagsWritesDescriptionToDatabase":0.001,"OpenAICompatibleTest::testGenerateTagsDescriptionUsesCorrectImageId":0,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDisabled":0,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty":0,"OpenAICompatibleTest::testGenerateTagsWithFreeTextFallback":0,"OpenAICompatibleTest::testFreeTextFallbackFiltersLongSentences":0,"OpenAICompatibleTest::testFreeTextFallbackFiltersEntryLongerThan50Chars":0,"OpenAICompatibleTest::testFreeTextFallbackRespectsLimit":0,"OpenAICompatibleTest::testFreeTextFallbackWritesRawContentAsDescription":0,"OpenAICompatibleTest::testGenerateTagsThrowsOnMalformedApiResponse":0,"OpenAICompatibleTest::testGenerateTagsThrowsOnConnectionError":0}} \ No newline at end of file diff --git a/README.md b/README.md index 8e0074b..12cbfdb 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,50 @@ Generate and automatically apply tags to a bunch of image on the batch manager. ## Available APIs - [Imagga](https://imagga.com) -- **New** [Microsft Azure](https://azure.microsoft.com/fr-fr/services/cognitive-services/computer-vision/) (you'll have to create an application on Azure) +- [Microsoft Azure Computer Vision](https://azure.microsoft.com/fr-fr/services/cognitive-services/computer-vision/) (you'll have to create an application on Azure) +- **New** OpenAI-compatible self-hosted endpoint (llama.cpp, Ollama, vLLM, LiteLLM, OpenAI, etc.) ## Usage * Install the plugin on your Piwigo -* Create an Imagga Account on https://imagga.com/auth/signup (or [here](https://azure.microsoft.com/fr-fr/free/cognitive-services/) for Microsoft Azure) -* Enter your api token and api secret on the plugin configuration page +* Create an Imagga Account on https://imagga.com/auth/signup (or [here](https://azure.microsoft.com/fr-fr/free/cognitive-services/) for Microsoft Azure), or set up a self-hosted vision model (see below) +* Enter your API credentials on the plugin configuration page * Go to an image modification page and click on the little robot next to the tags input * Generate tags, select the ones you want and apply them +### Self-hosted / OpenAI-compatible backend + +The **OpenAI-compatible** provider works with any server that implements the `/v1/chat/completions` endpoint with vision support. Common options: + +| Server | Example base URL | +|--------|-----------------| +| [Ollama](https://ollama.com) | `http://localhost:11434` | +| [llama.cpp](https://github.com/ggerganov/llama.cpp) | `http://localhost:8080` | +| [vLLM](https://github.com/vllm-project/vllm) | `http://localhost:8000` | +| [LiteLLM](https://github.com/BerriAI/litellm) | `http://localhost:4000` | +| OpenAI | `https://api.openai.com` | + +**Configuration fields:** + +| Field | Description | +|-------|-------------| +| API Base URL | Base URL of the server (the plugin appends `/v1/chat/completions`) | +| API Key | Bearer token — leave empty or set to `none` for local servers that don't require auth | +| Model Name | The vision-capable model to use (e.g. `llava`, `gpt-4o`, `llava-llama3`) | +| Max Tokens | Maximum tokens in the model's response (default 300) | +| Custom Prompt | Override the default JSON prompt. Leave empty to use the built-in prompt. | +| Write description as photo comment | When checked, the model's image description is also saved to the photo's comment field. | + +The plugin sends the image as a base64-encoded `image_url` content block, which is standard across all OpenAI-compatible vision APIs. + +**Example with Ollama:** +``` +# Pull a vision model +ollama pull llava + +# Start Ollama (it listens on port 11434 by default) +ollama serve +``` +Then set API Base URL to `http://localhost:11434` and Model Name to `llava`. + ### Warning -As this plugin use an external API, we cannot assure you that your data will not be used or sold. I recommend you to check the data policy of each external API you use with this plugin. \ No newline at end of file +As this plugin uses an external API, we cannot assure you that your data will not be used or sold. I recommend you to check the data policy of each external API you use with this plugin. Using a self-hosted model keeps all image data on your own infrastructure. \ No newline at end of file diff --git a/admin.php b/admin.php index 360ffea..4ad7b1a 100644 --- a/admin.php +++ b/admin.php @@ -24,35 +24,44 @@ $newConf->setSelectedAPI($_POST['api']); } - $apiObject = tr_getAPI($_POST['api']); + $apiObject = tr_getAPI($_POST['api']); + $fieldTypes = $apiObject->getConfFieldTypes(); foreach ($apiObject->getConfParams() as $key => $value) { - $newConf->setParam($_POST['api'], $key, trim($_POST[$key])); + $type = isset($fieldTypes[$key]) ? $fieldTypes[$key] : 'text'; + if ($type === 'checkbox') { + $newConf->setParam($_POST['api'], $key, isset($_POST[$key]) ? '1' : '0'); + } else { + $newConf->setParam($_POST['api'], $key, trim($_POST[$key] ?? '')); + } } tr_setConf($newConf); } } -$tr_api_info = []; -$tr_api_params = []; -$tr_api_conf = []; +$tr_api_info = []; +$tr_api_params = []; +$tr_api_conf = []; +$tr_api_field_types = []; foreach (TR_API_LIST as $apiName) { $apiObject = tr_getAPI($apiName); - $tr_api_info[$apiName] = $apiObject->getInfo(); - $tr_api_params[$apiName] = $apiObject->getConfParams(); - $tr_api_conf[$apiName] = tr_getConf()->getConf($apiName); + $tr_api_info[$apiName] = $apiObject->getInfo(); + $tr_api_params[$apiName] = $apiObject->getConfParams(); + $tr_api_conf[$apiName] = tr_getConf()->getConf($apiName); + $tr_api_field_types[$apiName] = $apiObject->getConfFieldTypes(); } $template->assign(array( - 'TR_PATH' => TR_PATH, - 'TR_API_LIST' => TR_API_LIST, - 'TR_API_INFO' => $tr_api_info, - 'TR_API_PARAMS' => $tr_api_params, - 'TR_API_CONF' => $tr_api_conf, - 'TR_API_SELECTED' => tr_getConf()->getSelectedAPI(), - 'PWG_TOKEN' => get_pwg_token() + 'TR_PATH' => TR_PATH, + 'TR_API_LIST' => TR_API_LIST, + 'TR_API_INFO' => $tr_api_info, + 'TR_API_PARAMS' => $tr_api_params, + 'TR_API_CONF' => $tr_api_conf, + 'TR_API_FIELD_TYPES' => $tr_api_field_types, + 'TR_API_SELECTED' => tr_getConf()->getSelectedAPI(), + 'PWG_TOKEN' => get_pwg_token() )); $template->set_filename('plugin_admin_content', realpath(TR_PATH . 'template/admin.tpl')); diff --git a/api_classes/OpenAICompatible.php b/api_classes/OpenAICompatible.php new file mode 100644 index 0000000..7a31c4a --- /dev/null +++ b/api_classes/OpenAICompatible.php @@ -0,0 +1,174 @@ + '', + "site" => 'https://platform.openai.com/docs/api-reference/chat', + "info" => 'Self-hosted OpenAI-compatible vision API. Works with any server that implements the /v1/chat/completions endpoint, including llama.cpp, Ollama, vLLM, LiteLLM, and OpenAI itself. Requires a vision-capable model.', + ]; + } + + function getConfParams() : array + { + return [ + 'ENDPOINT' => 'API Base URL', + 'API_KEY' => 'API Key (optional)', + 'MODEL' => 'Model Name', + 'MAX_TOKENS' => 'Max Tokens', + 'PROMPT' => 'Custom Prompt (optional)', + 'WRITE_DESCRIPTION' => 'Write description as photo comment', + ]; + } + + function getConfFieldTypes() : array + { + return [ + 'PROMPT' => 'textarea', + 'WRITE_DESCRIPTION' => 'checkbox', + ]; + } + + function generateTags($conf, $params) : array + { + $file_path = $this->getFileName($params['imageId']); + + if (empty($conf['ENDPOINT']) || empty($conf['MODEL'])) + throw new Exception('API parameters are not set'); + + // getFileName() returns a derivative URL or path. Try to resolve it to a + // filesystem path so file_get_contents() works without an HTTP round-trip. + $fs_path = $file_path; + if (defined('PHPWG_ROOT_PATH') && !preg_match('/^https?:\/\//', $file_path)) { + $candidate = realpath(PHPWG_ROOT_PATH . $file_path); + if ($candidate !== false && file_exists($candidate)) { + $fs_path = $candidate; + } + } + + $pathinfo = pathinfo($fs_path); + $mime_types = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + ]; + $ext = strtolower($pathinfo['extension'] ?? 'jpg'); + $mime_content_type = $mime_types[$ext] ?? 'image/jpeg'; + + $data = @file_get_contents($fs_path); + if ($data === false) + throw new Exception('Cannot read image file: ' . $fs_path); + + $base64 = base64_encode($data); + $dataUri = 'data:' . $mime_content_type . ';base64,' . $base64; + + $limit = (int)($params['limit'] ?? 20); + $prompt = !empty($conf['PROMPT']) + ? $conf['PROMPT'] + : 'Analyze this image and respond with a JSON object containing two keys: "description" (a 2-3 sentence description of the image) and "tags" (an array of up to ' . $limit . ' relevant keyword tags). Respond with only the JSON object, no markdown or extra text.'; + + $max_tokens = !empty($conf['MAX_TOKENS']) ? (int)$conf['MAX_TOKENS'] : 300; + + $payload = [ + 'model' => $conf['MODEL'], + 'max_tokens' => $max_tokens, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'text', + 'text' => $prompt, + ], + [ + 'type' => 'image_url', + 'image_url' => ['url' => $dataUri], + ], + ], + ], + ], + ]; + + $endpoint = rtrim($conf['ENDPOINT'], '/') . '/v1/chat/completions'; + + $api_key = !empty($conf['API_KEY']) ? $conf['API_KEY'] : 'none'; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $endpoint); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + // 120s is enough for most local models; increase for large models on slow hardware + curl_setopt($ch, CURLOPT_TIMEOUT, 120); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $api_key, + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + + $response = curl_exec($ch); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + throw new Exception('Connection error: ' . $error); + } + curl_close($ch); + + $json_response = json_decode($response); + + if (!isset($json_response->choices[0]->message->content)) + throw new Exception('API Error: ' . $response); + + $content = $json_response->choices[0]->message->content; + + // Strip markdown code fences that some models add + $content = preg_replace('/^```(?:json)?\s*/m', '', $content); + $content = preg_replace('/\s*```\s*$/m', '', $content); + $content = trim($content); + + $parsed = json_decode($content, true); + + $tags = []; + $description = ''; + + if (is_array($parsed)) { + if (!empty($parsed['tags']) && is_array($parsed['tags'])) { + $tags = array_slice(array_values($parsed['tags']), 0, $limit); + } + if (!empty($parsed['description']) && is_string($parsed['description'])) { + $description = $parsed['description']; + } + } else { + // Fallback for models that return free text instead of JSON. + // Use the raw content as the description and try to split it into tags. + $description = $content; + + $candidates = preg_split('/[\n,]+/', $content); + foreach ($candidates as $candidate) { + $candidate = trim($candidate); + // Skip empty strings, sentences (> 5 words), and overly long entries + if ($candidate === '') continue; + if (str_word_count($candidate) > 5) continue; + if (strlen($candidate) > 50) continue; + $tags[] = $candidate; + if (count($tags) >= $limit) break; + } + } + + if (!empty($conf['WRITE_DESCRIPTION']) && $conf['WRITE_DESCRIPTION'] === '1' && !empty($description)) { + $query = ' +UPDATE ' . IMAGES_TABLE . ' + SET comment = \'' . pwg_db_real_escape_string($description) . '\' + WHERE id = ' . ((int)$params['imageId']) . ' +;'; + pwg_query($query); + } + + return $tags; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..edeec3b --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "name": "piwigo/tag-recognition", + "description": "Piwigo plugin to suggest tags on images using external APIs", + "type": "piwigo-plugin", + "license": "MIT", + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "config": { + "platform": { + "php": "8.3" + } + } +} diff --git a/include/api_types.php b/include/api_types.php index df64123..bbb7315 100644 --- a/include/api_types.php +++ b/include/api_types.php @@ -1,6 +1,6 @@ + + + + tests + + + diff --git a/template/admin.tpl b/template/admin.tpl index f04717d..f5eb846 100644 --- a/template/admin.tpl +++ b/template/admin.tpl @@ -26,9 +26,23 @@ {foreach from=$TR_API_PARAMS[$apiName] item=label key=param} + {assign var=field_type value='text'} + {if isset($TR_API_FIELD_TYPES[$apiName][$param])} + {assign var=field_type value=$TR_API_FIELD_TYPES[$apiName][$param]} + {/if}
- - + {if $field_type eq 'checkbox'} + + {elseif $field_type eq 'textarea'} + + + {else} + + + {/if}
{/foreach} diff --git a/tests/OpenAICompatibleTest.php b/tests/OpenAICompatibleTest.php new file mode 100644 index 0000000..484bf03 --- /dev/null +++ b/tests/OpenAICompatibleTest.php @@ -0,0 +1,481 @@ +testFilePath; + } +} + +// --------------------------------------------------------------------------- + +class OpenAICompatibleTest extends TestCase +{ + private TestableOpenAICompatible $api; + private string $fixtureFile; + private string $serverBase; + + protected function setUp(): void + { + $GLOBALS['pwg_queries'] = []; + + $this->api = new TestableOpenAICompatible(); + $this->api->testFilePath = __DIR__ . '/fixtures/test_image.jpg'; + $this->fixtureFile = __DIR__ . '/fixtures/current_response.json'; + $this->serverBase = 'http://127.0.0.1:' . MOCK_SERVER_PORT; + } + + protected function tearDown(): void + { + if (file_exists($this->fixtureFile)) { + unlink($this->fixtureFile); + } + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /** Skip the test if the mock HTTP server failed to start. */ + private function requireServer(): void + { + if (empty($GLOBALS['mock_server_available'])) { + $this->markTestSkipped('Mock HTTP server could not be started on port ' . MOCK_SERVER_PORT . '.'); + } + } + + /** + * Build a minimal valid OpenAI chat-completion response envelope. + * $content is the raw string placed in choices[0].message.content. + */ + private function openAIEnvelope(string $content): array + { + return [ + 'id' => 'chatcmpl-test', + 'object' => 'chat.completion', + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => $content, + ], + 'finish_reason' => 'stop', + ], + ], + ]; + } + + /** Write $data as JSON to the fixture file the mock server will return. */ + private function setMockResponse(array $data): void + { + file_put_contents($this->fixtureFile, json_encode($data)); + } + + /** + * Build a full conf array, overriding individual keys as needed. + * Defaults point at the local mock server. + */ + private function conf(array $overrides = []): array + { + return array_merge([ + 'ENDPOINT' => $this->serverBase, + 'API_KEY' => 'test-key', + 'MODEL' => 'test-model', + 'MAX_TOKENS' => '100', + 'PROMPT' => '', + 'WRITE_DESCRIPTION' => '0', + ], $overrides); + } + + /** Build a params array, overriding individual keys as needed. */ + private function params(array $overrides = []): array + { + return array_merge([ + 'imageId' => 42, + 'language' => 'en', + 'limit' => 20, + ], $overrides); + } + + // ----------------------------------------------------------------------- + // Metadata tests — no HTTP call, no curl needed + // ----------------------------------------------------------------------- + + public function testGetInfoReturnsRequiredKeys(): void + { + $info = (new OpenAICompatible())->getInfo(); + + $this->assertIsArray($info); + $this->assertArrayHasKey('icon', $info); + $this->assertArrayHasKey('site', $info); + $this->assertArrayHasKey('info', $info); + + // site must be a non-empty string (used as href in the template) + $this->assertIsString($info['site']); + $this->assertNotEmpty($info['site']); + + // info must be a plain string (not null, not a shell execution result) + $this->assertIsString($info['info']); + $this->assertNotEmpty($info['info']); + } + + public function testGetConfParamsReturnsAllFields(): void + { + $params = (new OpenAICompatible())->getConfParams(); + + $this->assertIsArray($params); + + $expected = ['ENDPOINT', 'API_KEY', 'MODEL', 'MAX_TOKENS', 'PROMPT', 'WRITE_DESCRIPTION']; + foreach ($expected as $key) { + $this->assertArrayHasKey($key, $params, "Missing config param: $key"); + $this->assertIsString($params[$key], "Label for $key must be a string"); + $this->assertNotEmpty($params[$key], "Label for $key must not be empty"); + } + + $this->assertCount(6, $params); + } + + public function testGetConfFieldTypes(): void + { + $types = (new OpenAICompatible())->getConfFieldTypes(); + + $this->assertIsArray($types); + + $this->assertArrayHasKey('PROMPT', $types); + $this->assertArrayHasKey('WRITE_DESCRIPTION', $types); + $this->assertSame('textarea', $types['PROMPT']); + $this->assertSame('checkbox', $types['WRITE_DESCRIPTION']); + + // Fields that are plain text inputs must NOT appear in the type map + $this->assertArrayNotHasKey('ENDPOINT', $types); + $this->assertArrayNotHasKey('API_KEY', $types); + $this->assertArrayNotHasKey('MODEL', $types); + $this->assertArrayNotHasKey('MAX_TOKENS', $types); + } + + // ----------------------------------------------------------------------- + // Config-validation exceptions — fired before any HTTP call + // ----------------------------------------------------------------------- + + public function testGenerateTagsMissingEndpointThrowsException(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('API parameters are not set'); + + $this->api->generateTags($this->conf(['ENDPOINT' => '']), $this->params()); + } + + public function testGenerateTagsMissingModelThrowsException(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('API parameters are not set'); + + $this->api->generateTags($this->conf(['MODEL' => '']), $this->params()); + } + + public function testGenerateTagsThrowsWhenImageFileUnreadable(): void + { + $this->api->testFilePath = '/nonexistent/path/no_such_image.jpg'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Cannot read image file'); + + $this->api->generateTags($this->conf(), $this->params()); + } + + // ----------------------------------------------------------------------- + // Happy-path tag parsing — requires mock server + // ----------------------------------------------------------------------- + + public function testGenerateTagsWithValidJsonResponse(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'description' => 'A beach scene at sunset.', + 'tags' => ['beach', 'ocean', 'sunset'], + ]))); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertSame(['beach', 'ocean', 'sunset'], $tags); + } + + public function testGenerateTagsWithMarkdownFencedJson(): void + { + $this->requireServer(); + + $fenced = "```json\n" . json_encode([ + 'description' => 'A mountain landscape.', + 'tags' => ['mountain', 'landscape', 'snow', 'sky'], + ]) . "\n```"; + + $this->setMockResponse($this->openAIEnvelope($fenced)); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertSame(['mountain', 'landscape', 'snow', 'sky'], $tags); + } + + public function testGenerateTagsWithUnlabelledMarkdownFence(): void + { + $this->requireServer(); + + // Some models omit the "json" hint after the backticks + $fenced = "```\n" . json_encode([ + 'description' => 'A city skyline.', + 'tags' => ['city', 'skyline', 'night'], + ]) . "\n```"; + + $this->setMockResponse($this->openAIEnvelope($fenced)); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertSame(['city', 'skyline', 'night'], $tags); + } + + public function testGenerateTagsReturnsEmptyArrayWhenTagsKeyMissing(): void + { + $this->requireServer(); + + // Valid JSON but no "tags" key — should return [] without throwing + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'description' => 'No tags provided.', + ]))); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertSame([], $tags); + } + + // ----------------------------------------------------------------------- + // limit enforcement + // ----------------------------------------------------------------------- + + public function testGenerateTagsRespectsLimit(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'description' => 'Many tags.', + 'tags' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + ]))); + + $tags = $this->api->generateTags($this->conf(), $this->params(['limit' => 3])); + + $this->assertCount(3, $tags); + $this->assertSame(['a', 'b', 'c'], $tags); + } + + // ----------------------------------------------------------------------- + // WRITE_DESCRIPTION feature + // ----------------------------------------------------------------------- + + public function testGenerateTagsWritesDescriptionToDatabase(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'description' => 'A serene forest in autumn.', + 'tags' => ['forest', 'autumn'], + ]))); + + $this->api->generateTags( + $this->conf(['WRITE_DESCRIPTION' => '1']), + $this->params(['imageId' => 99]) + ); + + $this->assertNotEmpty($GLOBALS['pwg_queries'], 'Expected an UPDATE query to be executed'); + + $lastQuery = end($GLOBALS['pwg_queries']); + $this->assertStringContainsString('UPDATE', $lastQuery); + $this->assertStringContainsString('comment', $lastQuery); + $this->assertStringContainsString('A serene forest in autumn.', $lastQuery); + $this->assertStringContainsString('99', $lastQuery); + } + + public function testGenerateTagsDescriptionUsesCorrectImageId(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'description' => 'Test.', + 'tags' => ['test'], + ]))); + + $this->api->generateTags( + $this->conf(['WRITE_DESCRIPTION' => '1']), + $this->params(['imageId' => 777]) + ); + + $updateQuery = end($GLOBALS['pwg_queries']); + $this->assertStringContainsString('WHERE id = 777', $updateQuery); + } + + public function testGenerateTagsDoesNotWriteDescriptionWhenDisabled(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'description' => 'This should not be saved.', + 'tags' => ['tag1'], + ]))); + + $this->api->generateTags( + $this->conf(['WRITE_DESCRIPTION' => '0']), + $this->params() + ); + + $updateQueries = array_filter( + $GLOBALS['pwg_queries'], + static fn(string $q): bool => str_contains(strtoupper($q), 'UPDATE') + ); + $this->assertCount(0, $updateQueries, 'No UPDATE should be issued when WRITE_DESCRIPTION is disabled'); + } + + public function testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty(): void + { + $this->requireServer(); + + // JSON with no "description" key → description stays '' → no UPDATE + $this->setMockResponse($this->openAIEnvelope(json_encode([ + 'tags' => ['only', 'tags'], + ]))); + + $this->api->generateTags( + $this->conf(['WRITE_DESCRIPTION' => '1']), + $this->params() + ); + + $updateQueries = array_filter( + $GLOBALS['pwg_queries'], + static fn(string $q): bool => str_contains(strtoupper($q), 'UPDATE') + ); + $this->assertCount(0, $updateQueries, 'No UPDATE should fire when description is empty'); + } + + // ----------------------------------------------------------------------- + // Free-text fallback (model ignores JSON instructions) + // ----------------------------------------------------------------------- + + public function testGenerateTagsWithFreeTextFallback(): void + { + $this->requireServer(); + + // Model returns plain comma-separated keywords instead of JSON + $this->setMockResponse($this->openAIEnvelope('cat, dog, pet, animal')); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertNotEmpty($tags); + $this->assertContains('cat', $tags); + $this->assertContains('dog', $tags); + $this->assertContains('pet', $tags); + $this->assertContains('animal', $tags); + } + + public function testFreeTextFallbackFiltersLongSentences(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope( + "sunset\nThis is a very long sentence that should be filtered out\nocean" + )); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertContains('sunset', $tags); + $this->assertContains('ocean', $tags); + $this->assertNotContains( + 'This is a very long sentence that should be filtered out', + $tags + ); + } + + public function testFreeTextFallbackFiltersEntryLongerThan50Chars(): void + { + $this->requireServer(); + + $long = str_repeat('x', 51); // 51 chars, single word + $this->setMockResponse($this->openAIEnvelope("valid_tag\n{$long}\nanother_tag")); + + $tags = $this->api->generateTags($this->conf(), $this->params()); + + $this->assertContains('valid_tag', $tags); + $this->assertContains('another_tag', $tags); + $this->assertNotContains($long, $tags); + } + + public function testFreeTextFallbackRespectsLimit(): void + { + $this->requireServer(); + + $this->setMockResponse($this->openAIEnvelope("alpha\nbeta\ngamma\ndelta\nepsilon")); + + $tags = $this->api->generateTags($this->conf(), $this->params(['limit' => 2])); + + $this->assertCount(2, $tags); + } + + public function testFreeTextFallbackWritesRawContentAsDescription(): void + { + $this->requireServer(); + + $rawText = 'cat, dog, pet'; + $this->setMockResponse($this->openAIEnvelope($rawText)); + + $this->api->generateTags( + $this->conf(['WRITE_DESCRIPTION' => '1']), + $this->params(['imageId' => 7]) + ); + + $lastQuery = end($GLOBALS['pwg_queries']); + $this->assertNotFalse($lastQuery, 'Expected an UPDATE query for free-text description'); + $this->assertStringContainsString('cat, dog, pet', $lastQuery); + } + + // ----------------------------------------------------------------------- + // Error-handling + // ----------------------------------------------------------------------- + + public function testGenerateTagsThrowsOnMalformedApiResponse(): void + { + $this->requireServer(); + + // Server returns a valid HTTP 200 but an error body (no choices key) + $this->setMockResponse([ + 'error' => [ + 'message' => 'model not found', + 'type' => 'invalid_request_error', + ], + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('API Error'); + + $this->api->generateTags($this->conf(), $this->params()); + } + + public function testGenerateTagsThrowsOnConnectionError(): void + { + // Port 17891 has nothing listening → immediate ECONNREFUSED + $this->expectException(Exception::class); + $this->expectExceptionMessage('Connection error'); + + $this->api->generateTags( + $this->conf(['ENDPOINT' => 'http://127.0.0.1:17891']), + $this->params() + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..cd823f8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,166 @@ + 1, 'path' => 'test.jpg', 'representative_ext' => null]; +} + +function pwg_db_real_escape_string(string $str): string +{ + // Mirrors MySQL escaping closely enough for assertion purposes + return addslashes($str); +} + +function query2array(string $query, $key = null, $col = null): array +{ + return []; +} + +function conf_update_param(string $key, $value, bool $update = false): void +{ + $GLOBALS['pwg_conf'][$key] = $value; +} + +function safe_unserialize(string $str) +{ + return unserialize($str); +} + +function set_make_full_url(): void {} +function unset_make_full_url(): void {} +function fetchRemote(string $url, &$dest): void { $dest = ''; } + +// --------------------------------------------------------------------------- +// 4. Piwigo class stubs required by API::getFileName() +// TestableOpenAICompatible overrides getFileName() entirely, so these +// stubs only need to satisfy the abstract class at load time. +// --------------------------------------------------------------------------- + +class SrcImage +{ + public function __construct(array $info) {} +} + +class DerivativeImage +{ + public static function url(string $type, SrcImage $src): string + { + // Must NOT start with "i" to avoid the cache-generation branch + return '_data/i/stub_derivative.jpg'; + } +} + +// --------------------------------------------------------------------------- +// 5. Tiny test-image fixture +// Any readable file works; content is base64-encoded and sent to the mock +// server which ignores it. The .jpg extension drives MIME-type selection. +// --------------------------------------------------------------------------- + +$fixturesDir = __DIR__ . '/fixtures'; +$testImagePath = $fixturesDir . '/test_image.jpg'; + +if (!is_dir($fixturesDir)) { + mkdir($fixturesDir, 0755, true); +} + +if (!file_exists($testImagePath)) { + file_put_contents($testImagePath, 'FAKE_JPEG_CONTENT_FOR_TESTS'); +} + +// --------------------------------------------------------------------------- +// 6. Start the mock HTTP server (PHP built-in server) +// --------------------------------------------------------------------------- + +define('MOCK_SERVER_PORT', 17890); + +$GLOBALS['mock_server_proc'] = null; +$GLOBALS['mock_server_available'] = false; + +$serverScript = __DIR__ . '/mock_api_server.php'; + +$descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], +]; + +$proc = proc_open( + PHP_BINARY . ' -S 127.0.0.1:' . MOCK_SERVER_PORT . ' ' . escapeshellarg($serverScript), + $descriptors, + $pipes +); + +if (is_resource($proc)) { + $GLOBALS['mock_server_proc'] = $proc; + fclose($pipes[0]); + + // Wait up to 3 s for the server to accept connections + $deadline = microtime(true) + 3.0; + while (microtime(true) < $deadline) { + $sock = @fsockopen('127.0.0.1', MOCK_SERVER_PORT, $errno, $errstr, 0.1); + if ($sock !== false) { + fclose($sock); + $GLOBALS['mock_server_available'] = true; + break; + } + usleep(50_000); // 50 ms + } +} + +register_shutdown_function(static function (): void { + if (isset($GLOBALS['mock_server_proc']) && is_resource($GLOBALS['mock_server_proc'])) { + proc_terminate($GLOBALS['mock_server_proc']); + proc_close($GLOBALS['mock_server_proc']); + } + // Clean up the fixture file written per-test + $f = __DIR__ . '/fixtures/current_response.json'; + if (file_exists($f)) { + unlink($f); + } +}); + +// --------------------------------------------------------------------------- +// 7. Load plugin source +// api_types.php defines TR_API_LIST, the abstract API class, and +// auto-includes all three API class files via TR_PATH. +// --------------------------------------------------------------------------- + +include_once dirname(__DIR__) . '/include/api_types.php'; diff --git a/tests/fixtures/test_image.jpg b/tests/fixtures/test_image.jpg new file mode 100644 index 0000000..b56eb66 --- /dev/null +++ b/tests/fixtures/test_image.jpg @@ -0,0 +1 @@ +FAKE_JPEG_CONTENT_FOR_TESTS \ No newline at end of file diff --git a/tests/live_test.php b/tests/live_test.php new file mode 100644 index 0000000..cb84ae0 --- /dev/null +++ b/tests/live_test.php @@ -0,0 +1,485 @@ +tmpFiles as $f) { + if (file_exists($f)) { + unlink($f); + } + } + } + + public function getFileName($imageId): string + { + $query = ' +SELECT path + FROM ' . IMAGES_TABLE . ' + WHERE id = ' . ((int)$imageId) . ' +;'; + $row = pwg_db_fetch_assoc(pwg_query($query)); + if (!$row) { + throw new Exception("Image {$imageId} not found in database"); + } + + // DB path is like "./galleries/…" — resolve to absolute filesystem path + $abs = realpath(PHPWG_ROOT_PATH . ltrim($row['path'], './')); + if ($abs === false || !file_exists($abs)) { + throw new Exception('Image file not found on disk: ' . $row['path']); + } + + return $this->maybeScale($abs); + } + + /** + * If the image is larger than MAX_DIM on either axis, create a + * proportionally-scaled JPEG in /tmp and return its path. + * This mirrors what the plugin normally receives via the medium derivative. + */ + private function maybeScale(string $path): string + { + $size = @getimagesize($path); + if ($size === false) { + return $path; // not an image we can inspect; pass through + } + [$w, $h] = $size; + + if ($w <= self::MAX_DIM && $h <= self::MAX_DIM) { + return $path; // already small enough + } + + $scale = self::MAX_DIM / max($w, $h); + $newW = (int)round($w * $scale); + $newH = (int)round($h * $scale); + + $src = match($size[2]) { + IMAGETYPE_JPEG => imagecreatefromjpeg($path), + IMAGETYPE_PNG => imagecreatefrompng($path), + IMAGETYPE_WEBP => imagecreatefromwebp($path), + default => false, + }; + if ($src === false) { + return $path; // unsupported type; pass through as-is + } + + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $w, $h); + imagedestroy($src); + + $tmp = tempnam(sys_get_temp_dir(), 'piwigo_live_') . '.jpg'; + imagejpeg($dst, $tmp, 85); + imagedestroy($dst); + + $this->tmpFiles[] = $tmp; + return $tmp; + } +} + +// --------------------------------------------------------------------------- +// 3. Pick a real image from the DB +// --------------------------------------------------------------------------- + +hr('1 / 5 Selecting test image from Piwigo database'); + +$query = ' +SELECT id, path, comment + FROM ' . IMAGES_TABLE . ' + WHERE path IS NOT NULL + AND path != "" + ORDER BY id + LIMIT 20 +;'; +$result = pwg_query($query); + +$testImage = null; +while ($row = pwg_db_fetch_assoc($result)) { + $abs = realpath(PHPWG_ROOT_PATH . ltrim($row['path'], './')); + if ($abs !== false && file_exists($abs)) { + $testImage = $row; + break; + } +} + +if (!$testImage) { + fail('No usable image found in the database. Aborting.'); + exit(1); +} + +$imageId = (int) $testImage['id']; +$imagePath = realpath(PHPWG_ROOT_PATH . ltrim($testImage['path'], './')); +$originalComment = $testImage['comment'] ?? ''; + +label('Image ID', (string)$imageId); +label('File path', $imagePath); +label('File size', number_format(filesize($imagePath)) . ' bytes'); +label('Original comment', $originalComment !== '' ? "\"$originalComment\"" : '(empty)'); + +// --------------------------------------------------------------------------- +// 4. Shared configuration +// --------------------------------------------------------------------------- + +$MODEL = 'Qwen3-VL-30B-A3B-Instruct-UD-Q4_K_XL.gguf'; +$ENDPOINT = 'http://localhost:8082'; + +$baseConf = [ + 'ENDPOINT' => $ENDPOINT, + 'API_KEY' => '', + 'MODEL' => $MODEL, + 'MAX_TOKENS' => '500', + 'PROMPT' => '', + 'WRITE_DESCRIPTION' => '0', +]; + +$params = [ + 'imageId' => $imageId, + 'language' => 'en', + 'limit' => 10, +]; + +$api = new LiveTestOpenAICompatible(); + +// --------------------------------------------------------------------------- +// Helper: make a raw curl call and return the model's message content string, +// so we can display it before any parsing happens. +// --------------------------------------------------------------------------- +/** + * Scale $imagePath to at most $maxDim px on the longest axis (using GD) and + * return the binary JPEG. Falls back to the original file if GD can't decode + * it (e.g. unsupported format). + */ +function scaleImageData(string $imagePath, int $maxDim = 1024): string +{ + $data = @file_get_contents($imagePath); + if ($data === false) { return ''; } + + $size = @getimagesize($imagePath); + if ($size === false) { return $data; } + + [$w, $h] = $size; + if ($w <= $maxDim && $h <= $maxDim) { return $data; } + + $scale = $maxDim / max($w, $h); + $nw = (int)round($w * $scale); + $nh = (int)round($h * $scale); + + $src = match($size[2]) { + IMAGETYPE_JPEG => imagecreatefromjpeg($imagePath), + IMAGETYPE_PNG => imagecreatefrompng($imagePath), + IMAGETYPE_WEBP => imagecreatefromwebp($imagePath), + default => false, + }; + if ($src === false) { return $data; } + + $dst = imagecreatetruecolor($nw, $nh); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h); + imagedestroy($src); + + ob_start(); + imagejpeg($dst, null, 85); + imagedestroy($dst); + return ob_get_clean(); +} + +function rawModelCall(string $endpoint, string $model, int $maxTokens, + string $prompt, string $imagePath): string +{ + $data = scaleImageData($imagePath); + if ($data === '') { return '(could not read image file)'; } + + // scaleImageData() always returns JPEG bytes (or the original if no scaling + // was needed). Use the original extension's MIME for the data URI. + $ext = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION)); + $mimeMap = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png', + 'webp'=>'image/webp','gif'=>'image/gif']; + $mime = $mimeMap[$ext] ?? 'image/jpeg'; + // If we scaled (GD always outputs JPEG), override to image/jpeg + $size = @getimagesize($imagePath); + if ($size && max($size[0], $size[1]) > 1024) { + $mime = 'image/jpeg'; + } + $dataUri = 'data:' . $mime . ';base64,' . base64_encode($data); + + $payload = [ + 'model' => $model, + 'max_tokens' => $maxTokens, + 'messages' => [[ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => $prompt], + ['type' => 'image_url', 'image_url' => ['url' => $dataUri]], + ], + ]], + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, rtrim($endpoint, '/') . '/v1/chat/completions'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_TIMEOUT, 180); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', + 'Authorization: Bearer none']); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + $response = curl_exec($ch); + if (curl_errno($ch)) { $e = curl_error($ch); curl_close($ch); return "(curl error: $e)"; } + curl_close($ch); + + $decoded = json_decode($response, true); + return $decoded['choices'][0]['message']['content'] ?? "(unexpected response: $response)"; +} + +// --------------------------------------------------------------------------- +// 5. TEST A — Default JSON prompt, WRITE_DESCRIPTION = 1 +// --------------------------------------------------------------------------- + +hr('2 / 5 Test A: default JSON prompt (WRITE_DESCRIPTION = 1)'); + +$confA = array_merge($baseConf, ['WRITE_DESCRIPTION' => '1']); + +// --- 5a. Raw model output (separate call for display) ---------------------- +$defaultPrompt = 'Analyze this image and respond with a JSON object containing two keys: ' + . '"description" (a 2-3 sentence description of the image) and "tags" ' + . '(an array of up to 10 relevant keyword tags). Respond with only the ' + . 'JSON object, no markdown or extra text.'; + +info('Sending image to ' . $ENDPOINT . ' (model: ' . $MODEL . ') …'); +$t0 = microtime(true); +$rawContent = rawModelCall($ENDPOINT, $MODEL, 500, $defaultPrompt, $imagePath); +$elapsed = round(microtime(true) - $t0, 1); + +echo "\n"; +echo " \e[33mRaw model response content\e[0m ({$elapsed}s):\n"; +echo " " . str_replace("\n", "\n ", trim($rawContent)) . "\n\n"; + +// --- 5b. Run generateTags() (second API call through the plugin code) ------- +info('Running generateTags() via plugin code …'); +$t0 = microtime(true); +try { + $tags = $api->generateTags($confA, $params); + $elapsed = round(microtime(true) - $t0, 1); + ok("generateTags() returned in {$elapsed}s"); +} catch (Exception $e) { + fail('generateTags() threw: ' . $e->getMessage()); + exit(1); +} + +echo "\n \e[33mExtracted tags\e[0m:\n"; +if (empty($tags)) { + fail('No tags returned'); +} else { + foreach ($tags as $i => $tag) { + echo ' ' . ($i + 1) . '. ' . $tag . "\n"; + } +} + +// --- 5c. Verify the description was written to the DB ---------------------- +echo "\n"; +$row = pwg_db_fetch_assoc(pwg_query( + 'SELECT comment FROM ' . IMAGES_TABLE . ' WHERE id = ' . $imageId . ';' +)); +$writtenComment = $row['comment'] ?? ''; + +echo " \e[33mDescription written to DB\e[0m:\n"; +if ($writtenComment !== '' && $writtenComment !== $originalComment) { + ok("comment field updated"); + echo " " . wordwrap('"' . $writtenComment . '"', 66, "\n ", true) . "\n"; +} elseif ($writtenComment === '' ) { + fail("comment field is empty — description was not written"); +} else { + fail("comment field unchanged from original value"); +} + +// --------------------------------------------------------------------------- +// 6. TEST B — Custom plain-text prompt (free-text fallback path) +// --------------------------------------------------------------------------- + +hr('3 / 5 Test B: custom prompt — free-text fallback path'); + +$customPrompt = 'List 5 keywords for this image, separated by commas. No JSON, just the keywords.'; +$confB = array_merge($baseConf, [ + 'PROMPT' => $customPrompt, + 'WRITE_DESCRIPTION' => '1', +]); + +info('Prompt: "' . $customPrompt . '"'); +info('Calling ' . $ENDPOINT . ' …'); + +// Raw output +$t0 = microtime(true); +$rawFreeText = rawModelCall($ENDPOINT, $MODEL, 500, $customPrompt, $imagePath); +$elapsed = round(microtime(true) - $t0, 1); + +echo "\n"; +echo " \e[33mRaw model response content\e[0m ({$elapsed}s):\n"; +echo " " . str_replace("\n", "\n ", trim($rawFreeText)) . "\n\n"; + +// Through the plugin +info('Running generateTags() via plugin code …'); +$t0 = microtime(true); +try { + $tagsFallback = $api->generateTags($confB, $params); + $elapsed = round(microtime(true) - $t0, 1); + ok("generateTags() returned in {$elapsed}s"); +} catch (Exception $e) { + fail('generateTags() threw: ' . $e->getMessage()); + exit(1); +} + +echo "\n \e[33mFallback-extracted tags\e[0m:\n"; +if (empty($tagsFallback)) { + fail("Fallback returned no tags — model may have returned a sentence; " + . "check the raw output above and tune the prompt if needed"); +} else { + foreach ($tagsFallback as $i => $tag) { + echo ' ' . ($i + 1) . '. ' . $tag . "\n"; + } +} + +// Verify description written +$row = pwg_db_fetch_assoc(pwg_query( + 'SELECT comment FROM ' . IMAGES_TABLE . ' WHERE id = ' . $imageId . ';' +)); +$commentB = $row['comment'] ?? ''; +echo "\n"; +if ($commentB !== '' && $commentB !== $writtenComment) { + ok('DB comment updated with free-text content'); + echo " " . wordwrap('"' . $commentB . '"', 66, "\n ", true) . "\n"; +} elseif ($commentB === $writtenComment) { + fail('DB comment was NOT overwritten by Test B (still has Test A value)'); +} else { + fail('DB comment is empty after Test B'); +} + +// --------------------------------------------------------------------------- +// 7. Cleanup — restore original comment +// --------------------------------------------------------------------------- + +hr('4 / 5 Cleanup — restoring original comment'); + +$escapedOriginal = pwg_db_real_escape_string($originalComment); +pwg_query( + 'UPDATE ' . IMAGES_TABLE . ' + SET comment = \'' . $escapedOriginal . '\' + WHERE id = ' . $imageId . ';' +); + +$row = pwg_db_fetch_assoc(pwg_query( + 'SELECT comment FROM ' . IMAGES_TABLE . ' WHERE id = ' . $imageId . ';' +)); +$restored = $row['comment'] ?? ''; + +if ($restored === $originalComment) { + ok('Original comment restored: ' . ($originalComment !== '' ? '"' . $originalComment . '"' : '(empty)')); +} else { + fail('Comment restore mismatch — got: "' . $restored . '"'); +} + +// --------------------------------------------------------------------------- +// 8. Summary +// --------------------------------------------------------------------------- + +hr('5 / 5 Summary'); + +label('Image tested', "ID {$imageId} ({$imagePath})"); +label('Model', $MODEL); +label('Endpoint', $ENDPOINT); +label('Test A tags', implode(', ', $tags)); +label('Test B tags', implode(', ', $tagsFallback)); +label('DB comment restored', $restored === $originalComment ? 'yes' : 'NO — check manually'); + +echo "\n"; +ok('Live integration test complete'); +echo "\n"; diff --git a/tests/mock_api_server.php b/tests/mock_api_server.php new file mode 100644 index 0000000..34a7d48 --- /dev/null +++ b/tests/mock_api_server.php @@ -0,0 +1,33 @@ + 'mock_api_server: no fixture file at ' . $fixture]); + exit; +} + +$body = file_get_contents($fixture); +if ($body === false) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['error' => 'mock_api_server: cannot read fixture file']); + exit; +} + +http_response_code(200); +header('Content-Type: application/json'); +echo $body;