load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use app\miniapi\service\Catalog; use plugin\saiadmin\app\controller\water\WaterController; use plugin\saiadmin\app\model\water\WaterAccount; use plugin\saiadmin\app\model\water\WaterLedger; use plugin\saiadmin\app\model\water\WaterOperator; use plugin\saiadmin\app\model\water\WaterOrder; use plugin\saiadmin\app\model\water\WaterPackage; use plugin\saiadmin\app\model\water\WaterSku; use plugin\saiadmin\app\service\WaterBusiness; use plugin\saiadmin\exception\ApiException; use support\Request; $checks = 0; function check(bool $condition, string $message): void { global $checks; ++$checks; if (!$condition) { throw new RuntimeException($message); } } function rejects(callable $callback, string $message): void { try { $callback(); } catch (ApiException $exception) { check(true, $message); return; } throw new RuntimeException($message); } function packageRequest(array $input): Request { $body = json_encode($input, JSON_THROW_ON_ERROR); return new Request("POST /core/water/packages HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body); } $service = new WaterBusiness(); $tag = 'PKG-DELETE-' . bin2hex(random_bytes(4)); $connection = (new WaterPackage())->getConnection(); $before = WaterPackage::query()->orderBy('id')->get()->toArray(); $beforeLedger = WaterLedger::query()->count(); $beforeOrders = WaterOrder::query()->count(); $connection->beginTransaction(); try { $enterprise = $service->save('enterprise', ['name' => $tag, 'contact' => '测试', 'phone' => 'TEST', 'address' => '事务回滚地址'], 1)['id']; $sku = $service->save('sku', ['name' => $tag, 'liters' => 18.9], 1)['id']; $packageInput = ['sku_id' => $sku, 'buckets' => 30, 'price_cents' => 69900]; $id = $service->save('package', ['name' => $tag . '-delete'] + $packageInput, 1)['id']; $keepId = $service->save('package', ['name' => $tag . '-keep'] + $packageInput, 1)['id']; $orderId = $service->purchase(['enterprise_id' => $enterprise, 'package_id' => $id, 'reference' => $tag], 1)['id']; $order = WaterOrder::query()->findOrFail($orderId)->toArray(); $account = WaterAccount::query()->where('enterprise_id', $enterprise)->firstOrFail()->toArray(); $stock = WaterSku::query()->findOrFail($sku)->toArray(); $otherPackage = WaterPackage::query()->findOrFail($keepId)->toArray(); $auditCount = WaterLedger::query()->count(); $reflection = new ReflectionClass(WaterController::class); $controller = $reflection->newInstanceWithoutConstructor(); $adminId = $reflection->getProperty('adminId'); $adminId->setValue($controller, 0); rejects(fn () => $controller->save(packageRequest(['action' => 'delete', 'id' => $id]), 'packages'), 'unauthenticated deletion rejected'); $operator = WaterOperator::query()->insertGetId(['username' => $tag, 'password' => password_hash($tag, PASSWORD_DEFAULT), 'status' => 1]); $adminId->setValue($controller, (int) $operator); rejects(fn () => $controller->save(packageRequest(['action' => 'delete', 'id' => $id]), 'packages'), 'operator without write permission rejected'); $adminId->setValue($controller, 1); foreach ([null, 0, -1, 'invalid', 1.5, []] as $invalidId) { rejects(fn () => $controller->save(packageRequest(['action' => 'delete', 'id' => $invalidId]), 'packages'), 'invalid package ID rejected'); } $missingId = (int) WaterPackage::query()->max('id') + 100; rejects(fn () => $controller->save(packageRequest(['action' => 'delete', 'id' => $missingId]), 'packages'), 'missing package rejected'); rejects(fn () => $controller->save(packageRequest(['action' => 'delete', 'id' => $enterprise]), 'enterprises'), 'delete action limited to packages'); check(WaterPackage::query()->where('id', $id)->exists(), 'rejected requests leave package intact'); check(WaterLedger::query()->count() === $auditCount, 'rejected requests create no audits'); $response = json_decode($controller->save(packageRequest(['action' => 'delete', 'id' => (string) $id]), 'packages')->rawBody(), true, 512, JSON_THROW_ON_ERROR); check($response['code'] === 200 && $response['data']['id'] === $id, 'JSON delete returns selected ID'); check(!WaterPackage::query()->where('id', $id)->exists(), 'selected package physically deleted'); check(WaterPackage::query()->findOrFail($keepId)->toArray() === $otherPackage, 'other package unchanged'); check(WaterOrder::query()->findOrFail($orderId)->toArray() === $order, 'historical order snapshot retained'); check(WaterAccount::query()->findOrFail($account['id'])->toArray() === $account, 'enterprise entitlement retained'); check(WaterSku::query()->findOrFail($sku)->toArray() === $stock, 'inventory unchanged'); $listing = $service->listing('packages', ['keyword' => $tag]); check($listing['total'] === 1 && array_column($listing['data'], 'id') === [$keepId], 'admin listing excludes deleted package'); $optionsRequest = new Request('GET /core/water/packages/options?type=package&keyword=' . $tag . " HTTP/1.1\r\nHost: localhost\r\n\r\n"); $options = json_decode($controller->options($optionsRequest, 'packages')->rawBody(), true, 512, JSON_THROW_ON_ERROR); check(array_column($options['data'], 'id') === [$keepId], 'package selector excludes deleted package'); check(array_column((new Catalog())->listing(['keyword' => $tag])['items'], 'id') === [$keepId], 'mini-program catalog excludes deleted package'); $audit = WaterLedger::query()->where(['kind' => 'archive', 'reference' => 'package:' . $id])->orderByDesc('id')->firstOrFail(); check($audit->note === '删除套餐:' . $tag . '-delete' && (int) $audit->created_by === 1, 'deletion audit records actor and package'); check(WaterLedger::query()->count() === $auditCount + 1, 'one deletion audit added'); rejects(fn () => $controller->save(packageRequest(['action' => 'delete', 'id' => $id]), 'packages'), 'repeated deletion reports missing package'); check(WaterLedger::query()->count() === $auditCount + 1, 'repeated deletion creates no extra audit'); } finally { $connection->rollBack(); } check(WaterPackage::query()->orderBy('id')->get()->toArray() === $before, 'original package data restored after rollback'); check(WaterLedger::query()->count() === $beforeLedger && WaterOrder::query()->count() === $beforeOrders, 'audit and order fixtures rolled back'); check(!WaterOperator::query()->where('username', $tag)->exists() && !WaterSku::query()->where('name', $tag)->exists(), 'operator and inventory fixtures rolled back'); if (in_array('--http', $argv, true)) { $token = \Tinywan\Jwt\JwtToken::generateToken(['id' => 1, 'username' => 'admin', 'plat' => 'saiadmin', 'type' => 'web', 'client' => $tag, 'access_exp' => 120])['access_token']; $curl = curl_init('http://127.0.0.1:8787/core/water/packages'); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['action' => 'delete', 'id' => 0], JSON_THROW_ON_ERROR), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token], ]); $body = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); $response = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR); check($status === 200 && ($response['message'] ?? '') === '套餐ID必须是有效整数', 'live HTTP worker has loaded delete action; invalid ID changes no data'); unset($token); } echo "PASS: $checks package deletion checks; all fixtures rolled back.\n";