Files
zyt/server/tests/DifyUploadedReuseRecoveryTest.php
T
2026-10-27 14:03:35 +08:00

84 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace think\facade { class Log { public static function warning($m): void {} } }
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['reuseRecoveryConfig']; }
class DifyRequiredAttachmentUpload {
public static function prepare($endpoint, $key, $user, $files, $timeout, $abort, $options): array {
$GLOBALS['reuseRecoveryUploads'][] = $options['reuse_uploaded_files'];
$reused = $options['reuse_uploaded_files'];
return ['ok' => true, 'files' => array_map(static fn($f) => ['type' => $f['type'],
'transfer_method' => 'local_file', 'upload_file_id' => $reused ? 'stale-fixture' : 'fresh-fixture'], $files),
'uploaded_indices' => range(1, count($files)), 'skipped' => [],
'reuse_tokens' => [str_repeat('a', 64)], 'reuse_hit_count' => $reused ? count($files) : 0,
'reuse_miss_count' => $reused ? 0 : count($files),
'timings' => ['source_ms' => 1, 'upload_ms' => 2, 'ocr_ms' => 3, 'reuse_wait_ms' => 4]];
}
public static function invalidateReuse($tokens, $options): void { ++$GLOBALS['reuseRecoveryInvalidations']; }
}
function curl_init(): \stdClass { return new \stdClass(); }
function curl_setopt_array($h, array $o): bool { $h->options = $o; return true; }
function curl_exec($h) {
$o = $h->options; $payload = json_decode($o[CURLOPT_POSTFIELDS], true);
$GLOBALS['reuseRecoveryRequests'][] = $payload;
$stream = ($payload['response_mode'] ?? '') === 'streaming';
$first = count($GLOBALS['reuseRecoveryRequests']) === 1;
$scenario = $GLOBALS['reuseRecoveryScenario'] ?? 'stale';
$h->code = $first ? 400 : 200; $h->errno = 0;
if ($scenario === 'ambiguous') { $h->code = 503; $h->errno = CURLE_OPERATION_TIMEDOUT; }
if ($scenario === 'partial') $h->code = 200;
if (isset($o[CURLOPT_HEADERFUNCTION])) ($o[CURLOPT_HEADERFUNCTION])($h, 'HTTP/1.1 ' . $h->code . " Synthetic\r\n");
$body = $first ? json_encode(['code' => 'file_not_found', 'message' => 'synthetic uploaded file not found'])
: ($stream ? "data: {\"event\":\"message\",\"answer\":\"synthetic complete reply\"}\n\ndata: {\"event\":\"message_end\"}\n\n"
: '{"answer":"synthetic complete reply"}');
if ($scenario === 'partial') $body = "data: {\"event\":\"message\",\"answer\":\"already displayed\"}\n\n"
. "data: {\"event\":\"error\",\"code\":\"file_not_found\",\"message\":\"file not found\"}\n\n";
if ($stream) { ($o[CURLOPT_WRITEFUNCTION])($h, $body); return true; }
return $body;
}
function curl_errno($h): int { return $h->errno; }
function curl_getinfo($h, $option): int { return $h->code; }
function curl_close($h): void {}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
$reuseRecoveryConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages',
'timeout' => 5, 'max_files' => 20, 'required_attachment_transport' => 'upload',
'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-only']]];
foreach ([false, true] as $stream) {
$reuseRecoveryScenario = 'stale';
$reuseRecoveryUploads = []; $reuseRecoveryRequests = []; $reuseRecoveryInvalidations = 0; $timings = [];
$files = [['type' => 'image', 'url' => 'https://synthetic.invalid/one.png'],
['type' => 'image', 'url' => 'https://synthetic.invalid/two.png']];
$opts = ['required_files' => true, 'knowledge_mode' => 'evidence', 'request_deadline_ns' => hrtime(true) + 5000000000,
'on_attachment_timing' => static function ($m) use (&$timings): void { $timings[] = $m; }];
$deltas = [];
$result = $stream ? DifyChatService::streamChat('qwen', [], 'exact original question and full source', 'same-user',
static function ($s) use (&$deltas): void { $deltas[] = $s; }, null, $files, $opts)
: DifyChatService::chat('qwen', [], 'exact original question and full source', 'same-user', $files, $opts);
if (empty($result['ok']) || $reuseRecoveryUploads !== [true, false] || $reuseRecoveryInvalidations !== 1
|| count($reuseRecoveryRequests) !== 2 || count($timings) !== 2) throw new RuntimeException('Stale reused handle is reuploaded exactly once');
foreach ($reuseRecoveryRequests as $payload) {
if (count($payload['files']) !== 2 || $payload['user'] !== 'same-user'
|| !str_contains($payload['query'], 'exact original question and full source')) throw new RuntimeException('Fresh retry preserves complete originals and user');
}
if (($result['submitted_attachment_count'] ?? 0) !== 2 || str_contains(json_encode($result), str_repeat('a', 64))) {
throw new RuntimeException('Complete public receipt never leaks private reuse tokens');
}
}
foreach (['ambiguous', 'partial', 'cleanup_pending'] as $reuseRecoveryScenario) {
$reuseRecoveryUploads = []; $reuseRecoveryRequests = []; $reuseRecoveryInvalidations = 0;
$opts['can_start_attempt'] = static fn(): bool => $GLOBALS['reuseRecoveryScenario'] !== 'cleanup_pending'
|| $GLOBALS['reuseRecoveryRequests'] === [];
$result = DifyChatService::streamChat('qwen', [], 'exact original question and full source', 'same-user',
static fn(string $s): bool => true, null, $files, $opts);
if (count($reuseRecoveryRequests) !== 1 || count($reuseRecoveryUploads) !== 1 || $reuseRecoveryInvalidations !== 0) {
throw new RuntimeException('Ambiguous failure, displayed text, or pending cleanup must not reupload/restart');
}
}
echo "Stale uploaded handle recovery / one fresh original upload / complete source / private metadata: OK\n";
}