Files
xuetang/server/tests/TangDetectivePersistenceContractTest.php
T
2026-09-09 14:47:29 +08:00

267 lines
11 KiB
PHP

<?php
declare(strict_types=1);
// Pure in-memory doubles exercise the actual persistence/controller classes.
// This is NOT a MySQL locking, middleware authentication, or HTTP integration test.
namespace think\db\exception {
class PDOException extends \RuntimeException
{
public function getData(): array
{
return ['PDO Error Info' => ['Driver Error Code' => $this->getCode()]];
}
}
}
namespace think\facade {
final class Db
{
public static array $rows = [];
public static array $snapshot = [];
public static bool $transaction = false;
public static ?int $lockedUser = null;
public static int $insertError = 0;
public static int $writes = 0;
public static function name(string $table): TangQuery
{
if ($table !== 'tcm_tang_detective_progress') {
throw new \RuntimeException('Attempted unrelated business table access');
}
return new TangQuery();
}
public static function startTrans(): void
{
if (self::$transaction) {
throw new \RuntimeException('Leaked transaction');
}
self::$snapshot = self::$rows;
self::$transaction = true;
}
public static function commit(): void
{
self::$transaction = false;
self::$lockedUser = null;
}
public static function rollback(): void
{
self::$rows = self::$snapshot;
self::commit();
}
}
final class TangQuery
{
private array $conditions = [];
private bool $locked = false;
public function where(string $field, $value): self
{
$this->conditions[$field] = $value;
return $this;
}
public function lock(bool $locked): self
{
$this->locked = $locked;
return $this;
}
public function find(): ?array
{
if (!isset($this->conditions['user_id'])) {
throw new \RuntimeException('Query omitted server user identity');
}
if ($this->locked) {
if (!Db::$transaction) {
throw new \RuntimeException('Row lock requires transaction');
}
Db::$lockedUser = $this->conditions['user_id'];
}
return Db::$rows[$this->conditions['user_id']] ?? null;
}
public function insert(array $values): int
{
if (!Db::$transaction || Db::$lockedUser !== $values['user_id']) {
throw new \RuntimeException('Insert must follow own-user locked lookup');
}
if (Db::$insertError) {
$code = Db::$insertError;
Db::$insertError = 0;
throw new \think\db\exception\PDOException('SECRET_SQL_AND_PRIVATE_PAYLOAD', $code);
}
if (isset(Db::$rows[$values['user_id']])) {
throw new \think\db\exception\PDOException('SECRET_DUPLICATE_SQL', 1062);
}
Db::$rows[$values['user_id']] = $values;
++Db::$writes;
return 1;
}
public function update(array $values): int
{
$userId = $this->conditions['user_id'] ?? 0;
if (!Db::$transaction || Db::$lockedUser !== $userId) {
throw new \RuntimeException('Update requires own-user lock');
}
$row = Db::$rows[$userId] ?? [];
if (!isset($this->conditions['revision'], $this->conditions['story_generation'])
|| ($row['revision'] ?? null) !== $this->conditions['revision']
|| ($row['story_generation'] ?? null) !== $this->conditions['story_generation']) {
return 0;
}
Db::$rows[$userId] = array_merge($row, $values);
++Db::$writes;
return 1;
}
}
final class Log
{
public static array $entries = [];
public static function warning(string $message, array $context): void
{
self::$entries[] = [$message, $context];
}
}
}
namespace app\api\controller {
final class TangTestResponse extends \ArrayObject
{
public array $headers = [];
public function header(array $headers): self
{
$this->headers = $headers;
return $this;
}
}
class BaseApiController
{
protected int $userId;
protected object $request;
public function __construct(int $userId, object $request)
{
$this->userId = $userId;
$this->request = $request;
}
protected function data(array $data): TangTestResponse
{
return new TangTestResponse(['code' => 1, 'data' => $data]);
}
protected function fail(string $message, array $data = [], int $code = 0, int $show = 0): TangTestResponse
{
return new TangTestResponse(['code' => $code, 'show' => $show, 'msg' => $message, 'data' => $data]);
}
}
}
namespace {
require __DIR__ . '/../app/common/service/game/TangDetectiveProgressException.php';
require __DIR__ . '/../app/common/service/game/TangDetectiveProgress.php';
require __DIR__ . '/../app/api/logic/tcm/TangDetectiveLogic.php';
require __DIR__ . '/../app/api/controller/TangDetectiveController.php';
use app\api\controller\TangDetectiveController;
use app\api\logic\tcm\TangDetectiveLogic;
use app\common\service\game\TangDetectiveProgress;
use app\common\service\game\TangDetectiveProgressException;
use think\facade\Db;
use think\facade\Log;
$assertions = 0;
$expect = static function (bool $condition, string $message) use (&$assertions): void {
if (!$condition) {
throw new RuntimeException($message);
}
++$assertions;
};
$error = static function (string $code, callable $action) use ($expect): void {
try {
$action();
} catch (TangDetectiveProgressException $exception) {
$expect($exception->errorCode() === $code, 'stable error: ' . $code);
$expect(!str_contains($exception->getMessage(), 'SECRET'), 'database detail stays private');
return;
}
throw new RuntimeException('Missing error: ' . $code);
};
$requestObject = static function (string $method, string $raw = '', string $type = 'application/json'): object {
return new class($method, $raw, $type) {
public function __construct(private string $method, private string $raw, private string $type) {}
public function isGet(): bool { return $this->method === 'GET'; }
public function isPost(): bool { return $this->method === 'POST'; }
public function contentType(): string { return $this->type; }
public function getContent(): string { return $this->raw; }
};
};
$policy = new TangDetectiveProgress();
$logic = new TangDetectiveLogic($policy);
$body = [
'schema_version' => 1, 'content_version' => 'season-01',
'base_revision' => 0, 'story_generation' => 0,
'request_id' => 'own_user_request_001', 'operation' => 'replace',
'progress' => $policy->defaultProgress(),
];
$raw = json_encode($body, JSON_THROW_ON_ERROR);
$command = $policy->decodeRequest($raw);
$expect($logic->read(11)['revision'] === 0 && Db::$rows === [], 'empty read makes no database writes');
$save = $logic->save(11, $command);
$expect($save['revision'] === 1 && $save['user_id'] === 11, 'server identity owns first save');
$expect(!Db::$transaction && Db::$writes === 1, 'first save commits one write');
$save = $logic->save(11, $command);
$expect($save['idempotent'] && Db::$writes === 1, 'same request retry performs no update');
$expect($logic->read(12)['revision'] === 0, 'different user cannot read first user state');
$logic->save(12, $command);
$expect(count(Db::$rows) === 2, 'same request ID may be used by independent users');
$before = Db::$rows[11];
$stale = $command;
$stale['request_id'] = 'stale_request_00001';
$error('PROGRESS_CONFLICT', static fn () => $logic->save(11, $stale));
$expect(Db::$rows[11] === $before && !Db::$transaction, 'stale update rolls back without overwrite');
$next = $command;
$next['base_revision'] = 1;
$next['request_id'] = 'second_request_0001';
$expect($logic->save(11, $next)['revision'] === 2, 'matching revision updates under lock');
$error('PROGRESS_CONFLICT', static fn () => $logic->save(11, $command));
foreach ([1062, 1205, 1213] as $code) {
Db::$insertError = $code;
$error('PROGRESS_CONFLICT', static fn () => $logic->save(20, $command));
$expect(!isset(Db::$rows[20]) && !Db::$transaction, 'first-insert race leaves no partial state');
}
Db::$insertError = 1146;
$error('STORAGE_UNAVAILABLE', static fn () => $logic->save(20, $command));
$expect(!str_contains(json_encode(Log::$entries), 'SECRET'), 'logs exclude SQL and raw exception text');
$error('AUTH_REQUIRED', static fn () => $logic->read(0));
Db::$rows[12]['progress_json'] = '{"healthAnswer":"SECRET_PRIVATE_TEXT"}';
$error('STORAGE_UNAVAILABLE', static fn () => $logic->read(12));
$expect(!str_contains(json_encode(Log::$entries), 'SECRET'), 'corrupt stored text never enters logs');
$controller = new TangDetectiveController(11, $requestObject('GET'));
$expect($controller->progress()['data']['user_id'] === 11, 'controller returns only server identity');
$expect(count($controller->catalog()['data']['chapters']) === 15, 'catalog has no storage dependency');
$expect($controller->saveProgress()['data']['error_code'] === 'METHOD_NOT_ALLOWED', 'conventional GET save route cannot write');
$controller = new TangDetectiveController(0, $requestObject('GET'));
$expect($controller->catalog()['data']['error_code'] === 'AUTH_REQUIRED', 'catalog cannot bypass own-user requirement');
$controller = new TangDetectiveController(11, $requestObject('POST', $raw, 'text/plain'));
$expect($controller->saveProgress()['data']['error_code'] === 'UNSUPPORTED_MEDIA_TYPE', 'save rejects non-JSON media type');
$forged = $body + ['user_id' => 99];
$controller = new TangDetectiveController(11, $requestObject('POST', json_encode($forged)));
$expect($controller->saveProgress()['data']['error_code'] === 'INVALID_REQUEST', 'client identity field is rejected');
$controller = new TangDetectiveController(31, $requestObject('POST', $raw));
$result = $controller->saveProgress();
$expect($result['code'] === 1 && $result['data']['user_id'] === 31, 'controller passes only injected identity to persistence');
$expect($result->headers['Cache-Control'] === 'no-store', 'own-user state is never shared-cacheable');
echo "Tang Detective persistence/controller doubles: {$assertions} assertions OK\n";
}