gengxin
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
<?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";
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Pure PHP: no vendor bootstrap, database, server, network, or filesystem writes.
|
||||
require __DIR__ . '/../app/common/service/game/TangDetectiveProgressException.php';
|
||||
require __DIR__ . '/../app/common/service/game/TangDetectiveProgress.php';
|
||||
|
||||
use app\common\service\game\TangDetectiveProgress;
|
||||
use app\common\service\game\TangDetectiveProgressException;
|
||||
|
||||
$policy = new TangDetectiveProgress();
|
||||
$assertions = 0;
|
||||
$expect = static function (bool $condition, string $message) use (&$assertions): void {
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
++$assertions;
|
||||
};
|
||||
$expectError = static function (string $code, callable $action) use ($expect): void {
|
||||
try {
|
||||
$action();
|
||||
} catch (TangDetectiveProgressException $exception) {
|
||||
$expect($exception->errorCode() === $code, 'expected stable error: ' . $code);
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected error was not thrown: ' . $code);
|
||||
};
|
||||
$encode = static fn (array $value): string => json_encode($value, JSON_THROW_ON_ERROR);
|
||||
$command = static function (array $progress, int $revision = 0, int $generation = 0, string $id = 'test_request_00000001', string $operation = 'replace'): array {
|
||||
return [
|
||||
'schema_version' => 1, 'content_version' => 'season-01',
|
||||
'base_revision' => $revision, 'story_generation' => $generation,
|
||||
'request_id' => $id, 'operation' => $operation, 'progress' => $progress,
|
||||
];
|
||||
};
|
||||
$progressFor = static function (int $chapterNumber, int $events, bool $finished = false) use ($policy): array {
|
||||
$id = sprintf('S01-C%02d', $chapterNumber);
|
||||
$catalog = $policy->catalog()['chapters'][$chapterNumber - 1];
|
||||
$done = array_slice($catalog['hotspot_ids'], 0, $events);
|
||||
$page = sprintf('%s-P%02d', $id, $finished ? 8 : min(7, 3 + $events));
|
||||
return [
|
||||
'completedHotspots' => (object) [$id => $done],
|
||||
'completedChapters' => $finished ? [$id] : [],
|
||||
'lastChapter' => $chapterNumber,
|
||||
'collectedMemoryCards' => [],
|
||||
'comicReaderByChapter' => (object) [$id => (object) [
|
||||
'currentPageId' => $page, 'completedEventIds' => $done, 'chapterFinished' => $finished,
|
||||
]],
|
||||
'lastPageId' => $page,
|
||||
];
|
||||
};
|
||||
|
||||
$initial = $policy->defaultState(11);
|
||||
$expect($initial['user_id'] === 11 && $initial['revision'] === 0, 'own-user empty read is revision zero');
|
||||
$expect($initial['progress']['completedHotspots'] instanceof stdClass, 'empty maps serialize as objects');
|
||||
$expectError('AUTH_REQUIRED', static fn () => $policy->defaultState(0));
|
||||
$expect(count($policy->catalog()['chapters']) === 15, 'catalog contains 15 chapters');
|
||||
for ($chapter = 1; $chapter <= 15; ++$chapter) {
|
||||
for ($count = 0; $count <= 4; ++$count) {
|
||||
$parsed = $policy->decodeRequest($encode($command($progressFor($chapter, $count))));
|
||||
$id = sprintf('S01-C%02d', $chapter);
|
||||
$expect(count($parsed['progress']['completedHotspots']->$id) === $count, 'all canonical prefixes accepted');
|
||||
$expect($parsed['progress']['completedChapters'] === [], 'four events do not finish a chapter automatically');
|
||||
}
|
||||
$parsed = $policy->decodeRequest($encode($command($progressFor($chapter, 4, true))));
|
||||
$expect(count($parsed['progress']['completedChapters']) === 1, 'explicit emotion completion permits memory page');
|
||||
}
|
||||
|
||||
$valid = $command($progressFor(1, 1));
|
||||
$request = $policy->decodeRequest($encode($valid));
|
||||
$saved = $policy->transition($initial, $request);
|
||||
$expect($saved['state']['revision'] === 1 && !$saved['idempotent'], 'first save advances exactly one revision');
|
||||
$retry = $policy->transition($saved['state'], $request, $request['request_id'], $saved['request_hash']);
|
||||
$expect($retry['idempotent'] && $retry['state'] === $saved['state'], 'acknowledgement loss retries are idempotent');
|
||||
$changed = $request;
|
||||
$changed['progress']['lastPageId'] = '';
|
||||
$expectError('IDEMPOTENCY_CONFLICT', static fn () => $policy->transition(
|
||||
$saved['state'], $changed, $request['request_id'], $saved['request_hash']
|
||||
));
|
||||
$racing = $request;
|
||||
$racing['request_id'] = 'other_device_00000001';
|
||||
$expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($saved['state'], $racing));
|
||||
$expect($saved['state']['progress']['lastPageId'] === 'S01-C01-P04', 'conflicting copy never changes confirmed state');
|
||||
|
||||
$withCard = $progressFor(1, 4, true);
|
||||
$withCard['collectedMemoryCards'] = ['S01-C01-MC01'];
|
||||
$current = $policy->transition($initial, $policy->decodeRequest($encode($command($withCard))))['state'];
|
||||
$resetProgress = $policy->defaultProgress();
|
||||
$resetProgress['collectedMemoryCards'] = ['S01-C02-MC01'];
|
||||
$resetRequest = $policy->decodeRequest($encode($command($resetProgress, 1, 0, 'reset_request_000001', 'reset_story')));
|
||||
$reset = $policy->transition($current, $resetRequest);
|
||||
$expect($reset['state']['progress']['collectedMemoryCards'] === ['S01-C01-MC01', 'S01-C02-MC01'], 'reset preserves cloud and unsynced local card IDs');
|
||||
$expect($reset['state']['story_generation'] === 1 && $reset['state']['revision'] === 2, 'reset advances generation and revision');
|
||||
$expect(get_object_vars($reset['state']['progress']['comicReaderByChapter']) === [], 'reset empties reader bookmarks');
|
||||
$resetRetry = $policy->transition($reset['state'], $resetRequest, $resetRequest['request_id'], $reset['request_hash']);
|
||||
$expect($resetRetry['state']['story_generation'] === 1 && $resetRetry['idempotent'], 'reset retry never increments generation twice');
|
||||
$oldGeneration = $command($withCard, 2, 0, 'old_device_000000001');
|
||||
$expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($reset['state'], $policy->decodeRequest($encode($oldGeneration))));
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($withCard, 1, 0, 'reset_request_000002', 'reset_story'))));
|
||||
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('[]'));
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('{invalid'));
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('null'));
|
||||
$expectError('PAYLOAD_TOO_LARGE', static fn () => $policy->decodeRequest(str_repeat(' ', 32769)));
|
||||
$exact = $encode($valid);
|
||||
$exact .= str_repeat(' ', 32768 - strlen($exact));
|
||||
$expect($policy->decodeRequest($exact)['request_id'] === $valid['request_id'], 'exactly 32 KiB allowed');
|
||||
|
||||
$invalidBodies = [];
|
||||
$body = $valid;
|
||||
$body['user_id'] = 99;
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['progress']['memoryCardSnapshots'] = (object) ['private' => 'must not be stored'];
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['progress']['healthAnswer'] = 'private';
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['progress']['lastChapter'] = '1';
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['progress']['completedHotspots'] = [];
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['base_revision'] = -1;
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['request_id'] = 'short';
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['progress']['collectedMemoryCards'] = ['S01-C16-MC01'];
|
||||
$invalidBodies[] = $body;
|
||||
$body = $valid;
|
||||
$body['progress']['collectedMemoryCards'] = ['S01-C01-MC01', 'S01-C01-MC01'];
|
||||
$invalidBodies[] = $body;
|
||||
foreach ($invalidBodies as $body) {
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($body)));
|
||||
}
|
||||
foreach ([['S01-H02'], ['S01-H01', 'S01-H03'], ['S01-H02', 'S01-H01'], ['S01-H01', 'S01-H01'], ['S01-H05']] as $badPrefix) {
|
||||
$progress = $progressFor(1, 1);
|
||||
$progress['completedHotspots']->{'S01-C01'} = $badPrefix;
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
|
||||
}
|
||||
$progress = $progressFor(1, 3);
|
||||
$progress['completedChapters'] = ['S01-C01'];
|
||||
$progress['comicReaderByChapter']->{'S01-C01'}->chapterFinished = true;
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
|
||||
$progress = $progressFor(1, 4);
|
||||
$progress['comicReaderByChapter']->{'S01-C01'}->currentPageId = 'S01-C01-P08';
|
||||
$progress['lastPageId'] = 'S01-C01-P08';
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
|
||||
$progress = $progressFor(1, 1);
|
||||
$progress['comicReaderByChapter']->{'S01-C01'}->answer = 'private';
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
|
||||
$progress = $progressFor(1, 1);
|
||||
$progress['comicReaderByChapter']->{'S01-C01'}->completedEventIds = [];
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
|
||||
$progress = $progressFor(1, 1);
|
||||
$progress['lastPageId'] = 'S01-C02-P01';
|
||||
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
|
||||
$body = $valid;
|
||||
$body['schema_version'] = '1';
|
||||
$expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body)));
|
||||
$body = $valid;
|
||||
$body['content_version'] = 'season-02';
|
||||
$expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body)));
|
||||
|
||||
echo "Tang Detective progress contract: {$assertions} assertions OK\n";
|
||||
Reference in New Issue
Block a user