diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml
new file mode 100644
index 00000000..2e05005f
--- /dev/null
+++ b/.github/workflows/test-deploy.yml
@@ -0,0 +1,51 @@
+name: Test Suite after Deploy
+
+on:
+ workflow_run:
+ workflows: ['Deploy API to server']
+ types: [completed]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ test-and-report:
+ runs-on: ubuntu-latest
+ if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: master
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ extensions: mbstring, json
+
+ - name: Run all tests (unit + integration + E2E)
+ run: bash tests/run.sh
+ env:
+ E2E: 1
+ E2E_API_BASE: https://api.peviitor.ro
+
+ - name: Upload test report as artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-report-deploy
+ path: tests/report/
+ retention-days: 90
+
+ - name: FTP deploy test report to api.peviitor.ro
+ if: always()
+ uses: SamKirkland/FTP-Deploy-Action@v4.3.5
+ with:
+ server: ${{ secrets.FTP_HOST }}
+ username: ${{ secrets.FTP_USER }}
+ password: ${{ secrets.FTP_PASSWD }}
+ local-dir: tests/report/
+ server-dir: /api.peviitor.ro/tests/
diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml
new file mode 100644
index 00000000..3fc829a7
--- /dev/null
+++ b/.github/workflows/test-pr.yml
@@ -0,0 +1,35 @@
+name: Tests on PR
+
+on:
+ pull_request:
+ branches: [master]
+
+permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ extensions: mbstring, json
+
+ - name: Run tests (unit + integration)
+ run: bash tests/run.sh
+
+ - name: Upload test report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-report-pr
+ path: tests/report/
+ retention-days: 30
diff --git a/index.php b/index.php
index 340a92a3..495cbb25 100644
--- a/index.php
+++ b/index.php
@@ -83,6 +83,25 @@
}
.lang-toggle button:not(.active):hover { color: #5a4a3a; }
+ .test-badge-link {
+ display: inline-block;
+ margin-top: 0.6rem;
+ text-decoration: none;
+ }
+ .test-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: 0.3rem 0.75rem;
+ border-radius: 8px;
+ background: #2d2a24;
+ color: #f4e9d8;
+ transition: opacity 0.2s;
+ }
+ .test-badge:hover { opacity: 0.8; }
+
/* Card */
.card {
background: #fffcf9;
@@ -357,7 +376,10 @@
diff --git a/tests/e2e/TestSmoke.php b/tests/e2e/TestSmoke.php
new file mode 100644
index 00000000..dd411758
--- /dev/null
+++ b/tests/e2e/TestSmoke.php
@@ -0,0 +1,85 @@
+ $v) {
+ $h[] = "$k: $v";
+ }
+ global $API_BASE;
+ $opts = [
+ 'http' => [
+ 'method' => $method,
+ 'header' => implode("\r\n", $h),
+ 'content' => $body,
+ 'timeout' => 10,
+ 'ignore_errors' => true
+ ]
+ ];
+ $url = $API_BASE . $path;
+ $context = stream_context_create($opts);
+ $response = @file_get_contents($url, false, $context);
+
+ $httpCode = 0;
+ if (isset($http_response_header)) {
+ preg_match('#HTTP/[0-9.]+ (\d+)#', $http_response_header[0], $m);
+ $httpCode = (int)$m[1];
+ }
+
+ return [
+ 'code' => $httpCode,
+ 'body' => $response !== false ? json_decode($response, true) : null,
+ 'raw' => $response
+ ];
+}
+
+test("GET /v1/random/ returns 200", function() {
+ $res = apiE2E('GET', '/v1/random/');
+ assertEqual(200, $res['code'], "Expected 200, got {$res['code']}");
+ assertTrue(isset($res['body']['title']), "Response should contain title");
+ assertTrue(isset($res['body']['company']), "Response should contain company");
+ assertTrue(isset($res['body']['url']), "Response should contain url");
+});
+
+test("GET /v1/random/ returns valid JSON with expected fields", function() {
+ $res = apiE2E('GET', '/v1/random/');
+ assertEqual(200, $res['code']);
+ assertTrue(is_string($res['body']['title'] ?? null));
+ assertTrue(is_string($res['body']['company'] ?? null));
+ assertTrue(is_array($res['body']['location'] ?? null));
+ assertTrue(is_string($res['body']['cif'] ?? null));
+});
+
+test("DELETE /v1/empty/ without auth returns 401", function() {
+ $res = apiE2E('DELETE', '/v1/empty/', [
+ 'Content-Type' => 'application/json'
+ ], json_encode(['confirmation' => 'DELETE_ALL_DATA']));
+ assertEqual(401, $res['code']);
+});
+
+test("DELETE /v1/cleanjobs/ without auth returns 401", function() {
+ $res = apiE2E('DELETE', '/v1/cleanjobs/', [
+ 'Content-Type' => 'application/json'
+ ], json_encode(['confirmation' => 'CLEAN_COMPANY_JOBS', 'company' => 'TEST']));
+ assertEqual(401, $res['code']);
+});
+
+test("GET /v1/empty/ returns 405", function() {
+ $res = apiE2E('GET', '/v1/empty/');
+ assertEqual(405, $res['code']);
+});
+
+test("GET /v1/cleanjobs/ returns 405", function() {
+ $res = apiE2E('GET', '/v1/cleanjobs/');
+ assertEqual(405, $res['code']);
+});
+
+test("POST /v1/random/ returns 405", function() {
+ $res = apiE2E('POST', '/v1/random/');
+ assertEqual(405, $res['code']);
+});
+
+finish();
diff --git a/tests/generate-report.php b/tests/generate-report.php
new file mode 100644
index 00000000..cf28dcf8
--- /dev/null
+++ b/tests/generate-report.php
@@ -0,0 +1,123 @@
+ $suite['file'] ?? ''];
+ $total++;
+ if ($t['pass']) $passed++; else $failed++;
+ }
+}
+
+$duration = array_sum(array_column($allTests, 'time'));
+$pct = $total > 0 ? round($passed / $total * 100, 1) : 0;
+
+$statusColor = $failed > 0 ? '#dc3545' : '#28a745';
+$statusText = $failed > 0 ? "FAILED ($failed)" : "ALL PASSED";
+?>
+
+
+
+
+
+
Test Report — peviitor API
+
+
+
+
+
+
+
🧪 Test Report
+
peviitor API v1 — = date('Y-m-d H:i:s') ?>
+
+
+
+
+
+
+
= round($duration, 1) ?>ms
Duration
+
● = $statusText ?>
+
+
+
+ | Status | Test | File | Time |
+
+
+
+ | = $t['pass'] ? '✓' : '✗' ?> |
+
+ = htmlspecialchars($t['name']) ?>
+
+ = htmlspecialchars($t['error']) ?>
+
+ |
+
+ unit';
+ elseif (str_contains($f, 'TestAuth')) echo 'unit';
+ elseif (str_contains($f, 'TestValidation')) echo 'unit';
+ elseif (str_contains($f, 'TestQueryBuild')) echo 'unit';
+ elseif (str_contains($f, 'TestRandom')) echo 'int';
+ elseif (str_contains($f, 'TestEmpty')) echo 'int';
+ elseif (str_contains($f, 'TestCleanjobs')) echo 'int';
+ elseif (str_contains($f, 'TestSmoke')) echo 'e2e';
+ else echo htmlspecialchars($f);
+ ?>
+ |
+ = $t['time'] ?>ms |
+
+
+
+
+
+
+
+
+
diff --git a/tests/helpers.php b/tests/helpers.php
new file mode 100644
index 00000000..c7e2b906
--- /dev/null
+++ b/tests/helpers.php
@@ -0,0 +1,77 @@
+ $name,
+ 'pass' => true,
+ 'time' => round((microtime(true) - $start) * 1000, 2)
+ ];
+ } catch (Throwable $e) {
+ $GLOBALS['_tests'][] = [
+ 'name' => $name,
+ 'pass' => false,
+ 'time' => round((microtime(true) - $start) * 1000, 2),
+ 'error' => $e->getMessage(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine()
+ ];
+ }
+}
+
+function assertEqual(mixed $expected, mixed $actual, string $msg = ''): void {
+ if ($expected !== $actual) {
+ throw new Exception($msg ?: sprintf("Expected %s but got %s",
+ json_encode($expected, JSON_UNESCAPED_UNICODE),
+ json_encode($actual, JSON_UNESCAPED_UNICODE)));
+ }
+}
+
+function assertTrue(bool $condition, string $msg = ''): void {
+ if (!$condition) {
+ throw new Exception($msg ?: "Expected true but got false");
+ }
+}
+
+function assertThrows(callable $fn, string $msg = ''): void {
+ $threw = false;
+ try {
+ $fn();
+ } catch (Throwable) {
+ $threw = true;
+ }
+ if (!$threw) {
+ throw new Exception($msg ?: "Expected exception but none thrown");
+ }
+}
+
+function assertNotEqual(mixed $expected, mixed $actual, string $msg = ''): void {
+ if ($expected === $actual) {
+ throw new Exception($msg ?: sprintf("Expected not %s", json_encode($expected, JSON_UNESCAPED_UNICODE)));
+ }
+}
+
+function registerTestFile(string $file): void {
+ $GLOBALS['_test_file'] = basename($file, '.php');
+}
+
+function finish(): never {
+ $result = [
+ 'type' => 'suite',
+ 'file' => $GLOBALS['_test_file'],
+ 'tests' => $GLOBALS['_tests'],
+ 'total' => count($GLOBALS['_tests']),
+ 'passed' => count(array_filter($GLOBALS['_tests'], fn($t) => $t['pass'])),
+ 'failed' => count(array_filter($GLOBALS['_tests'], fn($t) => !$t['pass'])),
+ ];
+ echo json_encode($result, JSON_UNESCAPED_UNICODE) . "\n";
+ exit($result['failed'] > 0 ? 1 : 0);
+}
+
+function beforeAll(callable $fn): void { $fn(); }
+
+function afterAll(callable $fn): void { $fn(); }
diff --git a/tests/integration/TestCleanjobs.php b/tests/integration/TestCleanjobs.php
new file mode 100644
index 00000000..a7857a43
--- /dev/null
+++ b/tests/integration/TestCleanjobs.php
@@ -0,0 +1,126 @@
+ $company,
+ 'cif' => $cif,
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'X-Api-Key' => $key,
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(200, $res['code'], "Expected 200, got {$res['code']}: {$res['raw']}");
+ assertEqual('Jobs deleted successfully', $res['body']['message'] ?? '');
+ assertTrue(isset($res['body']['jobCount']), "Response should contain jobCount");
+ assertEqual($company, $res['body']['company'] ?? '');
+ assertEqual($cif, $res['body']['cif'] ?? '');
+});
+
+test("DELETE /v1/cleanjobs/ with wrong X-Api-Key returns 401", function() {
+ $body = json_encode([
+ 'company' => 'NUME SRL',
+ 'cif' => '12345678',
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'X-Api-Key' => 'wrong-key',
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(401, $res['code']);
+ assertTrue(str_contains($res['body']['error'] ?? '', 'Unauthorized'));
+});
+
+test("DELETE /v1/cleanjobs/ without X-Api-Key returns 401", function() {
+ $body = json_encode([
+ 'company' => 'NUME SRL',
+ 'cif' => '12345678',
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(401, $res['code']);
+});
+
+test("DELETE /v1/cleanjobs/ with wrong confirmation returns 400", function() {
+ $body = json_encode([
+ 'company' => 'NUME SRL',
+ 'cif' => '12345678',
+ 'confirmation' => 'WRONG'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'X-Api-Key' => md5('NUME SRL' . '12345678'),
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(400, $res['code']);
+ assertTrue(str_contains($res['body']['error'] ?? '', 'Confirmation'));
+});
+
+test("DELETE /v1/cleanjobs/ without identifiers returns 400", function() {
+ $body = json_encode([
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'X-Api-Key' => 'some-key',
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(400, $res['code']);
+});
+
+test("DELETE /v1/cleanjobs/ with brand lookup returns 200", function() {
+ $brand = 'ORANGE';
+ $key = md5($brand);
+
+ $body = json_encode([
+ 'brand' => $brand,
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'X-Api-Key' => $key,
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(200, $res['code'], "Expected 200, got {$res['code']}: {$res['raw']}");
+ assertEqual($brand, $res['body']['brand'] ?? '');
+});
+
+test("DELETE /v1/cleanjobs/ with unknown company returns 404", function() {
+ $company = 'UNKNOWN';
+ $key = md5($company);
+
+ $body = json_encode([
+ 'company' => $company,
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ $res = apiCall('DELETE', '/v1/cleanjobs/', [
+ 'X-Api-Key' => $key,
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(404, $res['code']);
+});
+
+test("GET /v1/cleanjobs/ returns 405", function() {
+ $res = apiCall('GET', '/v1/cleanjobs/');
+ assertEqual(405, $res['code']);
+});
+
+test("POST /v1/cleanjobs/ returns 405", function() {
+ $res = apiCall('POST', '/v1/cleanjobs/');
+ assertEqual(405, $res['code']);
+});
+
+finish();
diff --git a/tests/integration/TestEmpty.php b/tests/integration/TestEmpty.php
new file mode 100644
index 00000000..46758bb9
--- /dev/null
+++ b/tests/integration/TestEmpty.php
@@ -0,0 +1,53 @@
+ 'DELETE_ALL_DATA']);
+ $res = apiCall('DELETE', '/v1/empty/', [
+ 'X-API-Key' => 'test-key-123456',
+ 'X-Cleanup-Secret' => 'test-secret-789012',
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(200, $res['code'], "Expected 200, got {$res['code']}: {$res['raw']}");
+ assertEqual('Jobs deleted successfully', $res['body']['message'] ?? '');
+ assertTrue(isset($res['body']['jobsDeleted']), "Response should contain jobsDeleted");
+});
+
+test("DELETE /v1/empty/ without auth headers returns 401", function() {
+ $body = json_encode(['confirmation' => 'DELETE_ALL_DATA']);
+ $res = apiCall('DELETE', '/v1/empty/', [
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ assertEqual(401, $res['code']);
+});
+
+test("DELETE /v1/empty/ with wrong confirmation returns 200 (auth passes, empty deletes)", function() {
+ // With valid auth but no confirmation check... empty doesn't validate confirmation string actually
+ // It just deletes. Let's test it works.
+ $body = json_encode(['confirmation' => 'WRONG']);
+ $res = apiCall('DELETE', '/v1/empty/', [
+ 'X-API-Key' => 'test-key-123456',
+ 'X-Cleanup-Secret' => 'test-secret-789012',
+ 'Content-Type' => 'application/json'
+ ], $body);
+
+ // Empty endpoint doesn't validate confirmation body content (it accepts any body)
+ assertEqual(200, $res['code']);
+});
+
+test("GET /v1/empty/ returns 405", function() {
+ $res = apiCall('GET', '/v1/empty/');
+ assertEqual(405, $res['code']);
+ assertEqual('Only DELETE method allowed', $res['body']['error'] ?? '');
+});
+
+test("POST /v1/empty/ returns 405", function() {
+ $res = apiCall('POST', '/v1/empty/');
+ assertEqual(405, $res['code']);
+});
+
+finish();
diff --git a/tests/integration/TestRandom.php b/tests/integration/TestRandom.php
new file mode 100644
index 00000000..a3fc798a
--- /dev/null
+++ b/tests/integration/TestRandom.php
@@ -0,0 +1,39 @@
+ $v) {
+ $h[] = "$k: $v";
+ }
+ $opts = [
+ 'http' => [
+ 'method' => $method,
+ 'header' => implode("\r\n", $h),
+ 'content' => $body,
+ 'timeout' => 5,
+ 'ignore_errors' => true
+ ]
+ ];
+ $url = "http://127.0.0.1:$port$path";
+ $context = stream_context_create($opts);
+ $response = @file_get_contents($url, false, $context);
+
+ $httpCode = 0;
+ if (isset($http_response_header)) {
+ preg_match('#HTTP/[0-9.]+ (\d+)#', $http_response_header[0], $m);
+ $httpCode = (int)$m[1];
+ }
+
+ return [
+ 'code' => $httpCode,
+ 'body' => $response !== false ? json_decode($response, true) : null,
+ 'raw' => $response
+ ];
+}
diff --git a/tests/integration/mock-handler.php b/tests/integration/mock-handler.php
new file mode 100644
index 00000000..515aa371
--- /dev/null
+++ b/tests/integration/mock-handler.php
@@ -0,0 +1,76 @@
+ ['status' => 0, 'QTime' => 1]]);
+ return true;
+}
+
+// Company core brand lookup
+if (str_contains($uri, '/company/select')) {
+ if (str_contains($q, 'ORANGE')) {
+ echo json_encode([
+ 'response' => [
+ 'numFound' => 1,
+ 'docs' => [['id' => '12345678', 'company' => 'ORANGE SA']]
+ ]
+ ]);
+ } elseif (str_contains($q, 'GOOGLE')) {
+ echo json_encode([
+ 'response' => [
+ 'numFound' => 2,
+ 'docs' => [
+ ['id' => '11111111', 'company' => 'GOOGLE SRL'],
+ ['id' => '22222222', 'company' => 'GOOGLE ROMANIA SA']
+ ]
+ ]
+ ]);
+ } else {
+ echo json_encode(['response' => ['numFound' => 0, 'docs' => []]]);
+ }
+ return true;
+}
+
+// Job core
+if (str_contains($q, '*:*')) {
+ if ($rows > 0) {
+ echo json_encode([
+ 'response' => [
+ 'numFound' => 42,
+ 'docs' => [[
+ 'title' => 'Inginer IT',
+ 'company' => 'NUME SRL',
+ 'location' => ['București'],
+ 'workmode' => 'remote',
+ 'url' => 'https://example.com/job/1',
+ 'salary' => '5000-8000 RON',
+ 'tags' => ['python'],
+ 'cif' => '12345678',
+ 'date' => '2026-06-15T10:00:00Z',
+ 'status' => 'published'
+ ]]
+ ]
+ ]);
+ } else {
+ echo json_encode(['response' => ['numFound' => 42]]);
+ }
+ return true;
+}
+
+if (str_contains($q, 'company:"NUME SRL"') || str_contains($q, 'cif:12345678')) {
+ echo json_encode(['response' => ['numFound' => 5]]);
+} elseif (str_contains($q, 'company:"UNKNOWN"')) {
+ echo json_encode(['response' => ['numFound' => 0]]);
+} elseif (str_contains($q, 'cif:11111111') || str_contains($q, 'cif:22222222')) {
+ echo json_encode(['response' => ['numFound' => 3]]);
+} else {
+ echo json_encode(['response' => ['numFound' => 0]]);
+}
+return true;
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100755
index 00000000..36a45f9e
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,180 @@
+#!/bin/bash
+set -e
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+TESTS_DIR="$ROOT_DIR/tests"
+RESULTS_FILE="$TESTS_DIR/results.json"
+REPORT_FILE="$TESTS_DIR/report/index.html"
+API_PORT=8080
+MOCK_PORT=18983
+E2E_MODE=${E2E:-0}
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+echo "=============================="
+echo " peviitor API Test Suite"
+echo "=============================="
+echo ""
+
+# Clean previous results
+> "$RESULTS_FILE"
+
+# ===== UNIT TESTS =====
+echo -e "${YELLOW}[UNIT TESTS]${NC}"
+UNIT_FILES=$(ls "$TESTS_DIR"/unit/Test*.php 2>/dev/null || true)
+UNIT_PASSED=0
+UNIT_TOTAL=0
+UNIT_FAILED=0
+
+for f in $UNIT_FILES; do
+ NAME=$(basename "$f" .php)
+ OUTPUT=$(php "$f" 2>&1) || true
+ RESULT=$(echo "$OUTPUT" | grep '^{' | tail -1)
+ if [ -n "$RESULT" ]; then
+ echo "$RESULT" >> "$RESULTS_FILE"
+ FAILED=$(echo "$RESULT" | php -r 'echo json_decode(file_get_contents("php://stdin"))->failed;')
+ TOTAL=$(echo "$RESULT" | php -r 'echo json_decode(file_get_contents("php://stdin"))->total;')
+ UNIT_TOTAL=$((UNIT_TOTAL + TOTAL))
+ if [ "$FAILED" -gt 0 ]; then
+ echo -e " ${RED}✗${NC} $NAME ($FAILED/$TOTAL failed)"
+ UNIT_FAILED=$((UNIT_FAILED + FAILED))
+ else
+ echo -e " ${GREEN}✓${NC} $NAME ($TOTAL passed)"
+ UNIT_PASSED=$((UNIT_PASSED + TOTAL))
+ fi
+ else
+ echo -e " ${RED}✗${NC} $NAME (no result)"
+ echo "$OUTPUT" | while IFS= read -r line; do echo " $line"; done
+ UNIT_FAILED=$((UNIT_FAILED + 1))
+ fi
+done
+echo ""
+
+# ===== INTEGRATION TESTS =====
+echo -e "${YELLOW}[INTEGRATION TESTS]${NC}"
+
+# Backup and create test api.env
+if [ -f "$ROOT_DIR/api.env" ]; then
+ cp "$ROOT_DIR/api.env" "$ROOT_DIR/api.env.bak"
+fi
+
+cat > "$ROOT_DIR/api.env" << 'TESTENV'
+PROD_SERVER = 127.0.0.1:18983
+SOLR_USER =
+SOLR_PASS =
+CLEANUP_API_KEY = test-key-123456
+CLEANUP_SECRET = test-secret-789012
+NODE_ENV = test
+TESTENV
+
+cleanup() {
+ # Kill servers
+ kill $MOCK_PID $API_PID 2>/dev/null || true
+ # Restore api.env
+ if [ -f "$ROOT_DIR/api.env.bak" ]; then
+ mv "$ROOT_DIR/api.env.bak" "$ROOT_DIR/api.env"
+ fi
+ wait 2>/dev/null || true
+}
+trap cleanup EXIT INT TERM
+
+# Start mock Solr server
+php -S "127.0.0.1:$MOCK_PORT" "$TESTS_DIR/integration/mock-handler.php" > /dev/null 2>&1 &
+MOCK_PID=$!
+
+# Start API server
+php -S "127.0.0.1:$API_PORT" -t "$ROOT_DIR" > /dev/null 2>&1 &
+API_PID=$!
+
+# Wait for servers to be ready
+sleep 1
+
+INT_FILES=$(ls "$TESTS_DIR"/integration/Test*.php 2>/dev/null || true)
+INT_PASSED=0
+INT_TOTAL=0
+INT_FAILED=0
+
+for f in $INT_FILES; do
+ NAME=$(basename "$f" .php)
+ OUTPUT=$(php "$f" 2>&1) || true
+ RESULT=$(echo "$OUTPUT" | grep '^{' | tail -1)
+ if [ -n "$RESULT" ]; then
+ echo "$RESULT" >> "$RESULTS_FILE"
+ FAILED=$(echo "$RESULT" | php -r 'echo json_decode(file_get_contents("php://stdin"))->failed;')
+ TOTAL=$(echo "$RESULT" | php -r 'echo json_decode(file_get_contents("php://stdin"))->total;')
+ INT_TOTAL=$((INT_TOTAL + TOTAL))
+ if [ "$FAILED" -gt 0 ]; then
+ echo -e " ${RED}✗${NC} $NAME ($FAILED/$TOTAL failed)"
+ INT_FAILED=$((INT_FAILED + FAILED))
+ else
+ echo -e " ${GREEN}✓${NC} $NAME ($TOTAL passed)"
+ INT_PASSED=$((INT_PASSED + TOTAL))
+ fi
+ else
+ echo -e " ${RED}✗${NC} $NAME (no result)"
+ echo "$OUTPUT" | while IFS= read -r line; do echo " $line"; done
+ INT_FAILED=$((INT_FAILED + 1))
+ fi
+done
+
+# Kill servers before restoring env
+kill $MOCK_PID $API_PID 2>/dev/null || true
+wait 2>/dev/null || true
+echo ""
+
+# ===== E2E TESTS (only after deploy) =====
+E2E_PASSED=0
+E2E_TOTAL=0
+E2E_FAILED=0
+
+if [ "$E2E_MODE" = "1" ]; then
+ echo -e "${YELLOW}[E2E TESTS]${NC}"
+ E2E_FILES=$(ls "$TESTS_DIR"/e2e/Test*.php 2>/dev/null || true)
+ for f in $E2E_FILES; do
+ NAME=$(basename "$f" .php)
+ OUTPUT=$(php "$f" 2>&1) || true
+ RESULT=$(echo "$OUTPUT" | grep '^{' | tail -1)
+ if [ -n "$RESULT" ]; then
+ echo "$RESULT" >> "$RESULTS_FILE"
+ FAILED=$(echo "$RESULT" | php -r 'echo json_decode(file_get_contents("php://stdin"))->failed;')
+ TOTAL=$(echo "$RESULT" | php -r 'echo json_decode(file_get_contents("php://stdin"))->total;')
+ E2E_TOTAL=$((E2E_TOTAL + TOTAL))
+ if [ "$FAILED" -gt 0 ]; then
+ echo -e " ${RED}✗${NC} $NAME ($FAILED/$TOTAL failed)"
+ E2E_FAILED=$((E2E_FAILED + FAILED))
+ else
+ echo -e " ${GREEN}✓${NC} $NAME ($TOTAL passed)"
+ E2E_PASSED=$((E2E_PASSED + TOTAL))
+ fi
+ else
+ echo -e " ${RED}✗${NC} $NAME (no result)"
+ E2E_FAILED=$((E2E_FAILED + 1))
+ fi
+ done
+fi
+
+# ===== GENERATE HTML REPORT =====
+php "$TESTS_DIR/generate-report.php" "$RESULTS_FILE" > "$REPORT_FILE"
+echo -e "${YELLOW}[REPORT]${NC} $REPORT_FILE"
+echo ""
+
+# ===== SUMMARY =====
+TOTAL_PASSED=$((UNIT_PASSED + INT_PASSED + E2E_PASSED))
+TOTAL_FAILED=$((UNIT_FAILED + INT_FAILED + E2E_FAILED))
+TOTAL_TOTAL=$((UNIT_TOTAL + INT_TOTAL + E2E_TOTAL))
+
+echo "=============================="
+echo " SUMMARY"
+echo " Total: $TOTAL_TOTAL | Passed: $TOTAL_PASSED | Failed: $TOTAL_FAILED"
+if [ "$TOTAL_FAILED" -gt 0 ]; then
+ echo -e " Result: ${RED}FAILED${NC}"
+else
+ echo -e " Result: ${GREEN}PASSED${NC}"
+fi
+echo "=============================="
+
+exit $TOTAL_FAILED
diff --git a/tests/unit/TestAuth.php b/tests/unit/TestAuth.php
new file mode 100644
index 00000000..572f04de
--- /dev/null
+++ b/tests/unit/TestAuth.php
@@ -0,0 +1,63 @@
+ 'cif:' . solrEscape($c), $brandCifs));
+ if (!empty($parts)) {
+ $parts[] = '(' . $cifList . ')';
+ } else {
+ $parts[] = $cifList;
+ }
+ }
+ return implode(' AND ', $parts);
+}
+
+test("query with company only", function() {
+ $q = buildDeleteQuery('NUME SRL', null);
+ assertEqual('company:"NUME SRL"', $q);
+});
+
+test("query with cif only", function() {
+ $q = buildDeleteQuery(null, '12345678');
+ assertEqual('cif:12345678', $q);
+});
+
+test("query with company and cif", function() {
+ $q = buildDeleteQuery('NUME SRL', '12345678');
+ assertEqual('cif:12345678 AND company:"NUME SRL"', $q);
+});
+
+test("query with brand from lookup", function() {
+ $q = buildDeleteQuery(null, null, ['111', '222']);
+ assertEqual('cif:111 OR cif:222', $q);
+});
+
+test("query with company and brand", function() {
+ $q = buildDeleteQuery('NUME SRL', null, ['111', '222']);
+ assertEqual('company:"NUME SRL" AND (cif:111 OR cif:222)', $q);
+});
+
+test("query escapes special chars in company", function() {
+ $q = buildDeleteQuery('Test "Company"', null);
+ assertEqual('company:"Test \\"Company\\""', $q);
+});
+
+test("query with company cif and brand", function() {
+ $q = buildDeleteQuery('NUME SRL', '12345', ['111']);
+ assertEqual('cif:12345 AND company:"NUME SRL" AND (cif:111)', $q);
+});
+
+test("empty query when nothing given", function() {
+ $q = buildDeleteQuery(null, null);
+ assertEqual('', $q);
+});
+
+test("query with cif containing special chars", function() {
+ $q = buildDeleteQuery(null, '123+456');
+ assertEqual('cif:123\\+456', $q);
+});
+
+finish();
diff --git a/tests/unit/TestSolrEscape.php b/tests/unit/TestSolrEscape.php
new file mode 100644
index 00000000..8a13b42f
--- /dev/null
+++ b/tests/unit/TestSolrEscape.php
@@ -0,0 +1,56 @@
+ $max) {
+ return "$label too long (max $max characters)";
+ }
+ return null;
+}
+
+function validateBody(array $body): array {
+ $errors = [];
+
+ $company = isset($body['company']) ? trim($body['company']) : null;
+ $cif = isset($body['cif']) ? trim($body['cif']) : null;
+ $brand = isset($body['brand']) ? trim($body['brand']) : null;
+
+ $err = validateIdentifier($company, $cif, $brand);
+ if ($err) $errors[] = $err;
+
+ $err = validateConfirmation($body['confirmation'] ?? null);
+ if ($err) $errors[] = $err;
+
+ $err = validateLength($company, 200, 'Company name');
+ if ($err) $errors[] = $err;
+
+ $err = validateLength($cif, 20, 'CIF');
+ if ($err) $errors[] = $err;
+
+ $err = validateLength($brand, 200, 'Brand name');
+ if ($err) $errors[] = $err;
+
+ return $errors;
+}
+
+test("rejects empty body", function() {
+ $errors = validateBody([]);
+ assertTrue(count($errors) > 0);
+ assertTrue(str_contains($errors[0], 'at least one'));
+});
+
+test("accepts company only", function() {
+ $errors = validateBody([
+ 'company' => 'NUME SRL',
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertEqual(0, count($errors));
+});
+
+test("accepts cif only", function() {
+ $errors = validateBody([
+ 'cif' => '12345678',
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertEqual(0, count($errors));
+});
+
+test("accepts brand only", function() {
+ $errors = validateBody([
+ 'brand' => 'ORANGE',
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertEqual(0, count($errors));
+});
+
+test("rejects wrong confirmation", function() {
+ $errors = validateBody([
+ 'company' => 'NUME SRL',
+ 'confirmation' => 'WRONG'
+ ]);
+ assertEqual(1, count($errors));
+ assertTrue(str_contains($errors[0], 'Confirmation'));
+});
+
+test("rejects missing confirmation", function() {
+ $errors = validateBody([
+ 'company' => 'NUME SRL'
+ ]);
+ assertTrue(count($errors) > 0);
+});
+
+test("rejects company name too long", function() {
+ $errors = validateBody([
+ 'company' => str_repeat('A', 201),
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertTrue(count($errors) > 0);
+ assertTrue(str_contains($errors[0], 'too long'));
+});
+
+test("accepts company at max length", function() {
+ $errors = validateBody([
+ 'company' => str_repeat('A', 200),
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertEqual(0, count($errors));
+});
+
+test("rejects cif too long", function() {
+ $errors = validateBody([
+ 'cif' => str_repeat('1', 21),
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertTrue(count($errors) > 0);
+});
+
+test("rejects brand too long", function() {
+ $errors = validateBody([
+ 'brand' => str_repeat('B', 201),
+ 'confirmation' => 'CLEAN_COMPANY_JOBS'
+ ]);
+ assertTrue(count($errors) > 0);
+});
+
+finish();