Accept immutable reviewed content from ATOM with idempotent site publication
This commit is contained in:
parent
bc0e9fa79e
commit
5e36c46b29
5
codex/output/content-center/README.md
Normal file
5
codex/output/content-center/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# TTC 内容中心接收端
|
||||
|
||||
接口和跨项目契约唯一正文:[ATOM内容中心](/Users/hellcat/Documents/GemCodes/atom/codex/output/docs/content-center.md)。
|
||||
本项目新增 laravel/app/ContentCenter、content-center/routes.php,由bootstrap/app.php独立加载;不改原文章控制器、商家规则、旧后台或并行导航/样式。预览不写文章,发布须匹配人工批准的最终稿哈希,并复用既有Ranking发布器。启用新流程时旧文章接收API410,其他站点不变。
|
||||
测试使用独立临时站点及模拟构建器,不发布真实文章;本次没有人工批准生产稿件,AI服务尚未配置。
|
||||
@ -64,3 +64,9 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Content center service credentials, never browser login credentials.
|
||||
CONTENT_ENVIRONMENT=local
|
||||
CONTENT_CENTER_ENABLED=false
|
||||
CONTENT_ATOM_URL=
|
||||
CONTENT_ATOM_KEY=
|
||||
|
||||
35
laravel/app/ContentCenter/ArticleArtifact.php
Normal file
35
laravel/app/ContentCenter/ArticleArtifact.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\ContentCenter;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
final class ArticleArtifact
|
||||
{
|
||||
public function build(array $data): array
|
||||
{
|
||||
validator($data, ['source_key' => 'required|string|max:160', 'revision' => 'required|string|size:64', 'generation' => 'required|integer|min:1', 'title' => 'required|string|max:200', 'text' => 'present|string|max:100000', 'sent_at' => 'required|date', 'images' => 'present|array|max:30', 'images.*.sha256' => 'required|regex:/^[a-f0-9]{64}$/D', 'images.*.mime' => 'required|in:image/png,image/jpeg,image/webp,image/gif', 'images.*.bytes' => 'required|integer|min:1|max:20971520'])->validate();
|
||||
$code = 'telefox-'.substr(hash('sha256', $data['source_key']), 0, 24);
|
||||
$body = "<div v-pre>\n".implode("\n", array_map(fn ($s) => '<p>'.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</p>', explode("\n", $data['text'])))."\n</div>\n\n";
|
||||
foreach ($data['images'] as $image) {
|
||||
$body .= '.")\n\n";
|
||||
}
|
||||
$meta = ['title' => $data['title'], 'createTime' => $data['sent_at'], 'permalink' => '/jccp/'.$code.'/', 'articleCode' => $code, 'tags' => ['频道导入'], 'sourceKey' => $data['source_key']];
|
||||
$document = ['meta' => $meta, 'body' => $body];
|
||||
if (class_exists(\App\Services\Ranking\ArticleDocument::class)) {
|
||||
$document = \App\Services\Ranking\ArticleDocument::enrich($document);
|
||||
}
|
||||
|
||||
return ['source_key' => $data['source_key'], 'revision' => $data['revision'], 'generation' => $data['generation'], 'article_code' => $code, 'relative_path' => 'jccp/'.$code.'.md', 'permalink' => $meta['permalink'], 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']];
|
||||
}
|
||||
|
||||
public static function extension(string $mime): string
|
||||
{
|
||||
return ['image/png' => '.png', 'image/jpeg' => '.jpg', 'image/webp' => '.webp', 'image/gif' => '.gif'][$mime];
|
||||
}
|
||||
|
||||
public static function hash(array $artifact): string
|
||||
{
|
||||
return hash('sha256', json_encode($artifact, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
25
laravel/app/ContentCenter/ContentAuthenticate.php
Normal file
25
laravel/app/ContentCenter/ContentAuthenticate.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\ContentCenter;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class ContentAuthenticate
|
||||
{
|
||||
public function handle(Request $r, Closure $next, string $role = 'ttc')
|
||||
{
|
||||
$key = (string) config('content_center.'.$role.'_key');
|
||||
abort_if(strlen($key) < 32, 503, '内容接口尚未配置。');
|
||||
$time = (string) $r->header('X-Content-Time');
|
||||
$nonce = (string) $r->header('X-Content-Nonce');
|
||||
abort_unless(ctype_digit($time) && abs(time() - (int) $time) <= 300 && preg_match('/^[a-f0-9]{32}$/D', $nonce), 401);
|
||||
abort_unless($r->header('X-Content-Environment') === config('content_center.environment'), 401);
|
||||
$data = $time."\n".$nonce."\n".$r->method()."\n".$r->getRequestUri()."\n".hash('sha256', $r->getContent());
|
||||
abort_unless(hash_equals(hash_hmac('sha256', $data, $key), (string) $r->header('X-Content-Signature')), 401);
|
||||
abort_unless(Cache::add('content-nonce:'.$role.':'.$nonce, true, 600), 409);
|
||||
|
||||
return $next($r);
|
||||
}
|
||||
}
|
||||
22
laravel/app/ContentCenter/ContentController.php
Normal file
22
laravel/app/ContentCenter/ContentController.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\ContentCenter;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ContentController
|
||||
{
|
||||
public function preview(Request $request)
|
||||
{
|
||||
$artifact = app(ArticleArtifact::class)->build($request->all());
|
||||
|
||||
return response()->json(['environment' => config('content_center.environment'), 'artifact' => $artifact, 'artifact_hash' => ArticleArtifact::hash($artifact)]);
|
||||
}
|
||||
|
||||
public function publish(Request $request)
|
||||
{
|
||||
abort_unless(config('content_center.enabled'), 503);
|
||||
|
||||
return response()->json(['environment' => config('content_center.environment'), 'receipt' => app(Publication::class)->publish($request->all())]);
|
||||
}
|
||||
}
|
||||
36
laravel/app/ContentCenter/ContentHttp.php
Normal file
36
laravel/app/ContentCenter/ContentHttp.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\ContentCenter;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class ContentHttp
|
||||
{
|
||||
public function request(string $base, string $key, string $method, string $path, ?array $data = null, int $timeout = 60)
|
||||
{
|
||||
$url = parse_url($base);
|
||||
$local = config('content_center.environment') === 'local';
|
||||
if (strlen($key) < 32 || ! $url || isset($url['user']) || isset($url['pass']) || isset($url['query']) || isset($url['fragment']) || ! empty($url['path']) && $url['path'] !== '/' || ! in_array($url['scheme'] ?? '', $local ? ['http', 'https'] : ['https'], true)) {
|
||||
throw new RuntimeException('内容连接配置无效。');
|
||||
}
|
||||
$body = $data === null ? '' : json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$time = (string) time();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$signature = hash_hmac('sha256', $time."\n".$nonce."\n".$method."\n".$path."\n".hash('sha256', $body), $key);
|
||||
try {
|
||||
$response = Http::timeout($timeout)->connectTimeout(10)->withoutRedirecting()->withOptions(['progress' => static function ($total, $received) {
|
||||
if ($total > 26214400 || $received > 26214400) {
|
||||
throw new RuntimeException('响应过大。');
|
||||
}
|
||||
}])->withHeaders(['X-Content-Time' => $time, 'X-Content-Nonce' => $nonce, 'X-Content-Environment' => config('content_center.environment'), 'X-Content-Signature' => $signature, 'Accept' => 'application/json'])->withBody($body, 'application/json')->send($method, rtrim($base, '/').$path);
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('内容服务请求未成功。');
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('内容服务暂不可用,请按同一发布编号重试。');
|
||||
}
|
||||
}
|
||||
}
|
||||
16
laravel/app/ContentCenter/ProtectLegacyImport.php
Normal file
16
laravel/app/ContentCenter/ProtectLegacyImport.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\ContentCenter;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ProtectLegacyImport
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
abort_if(config('content_center.enabled') && $request->is('api/article/receive/schema'), 410, 'TTC文章接收已迁至ATOM内容审核流程。');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
104
laravel/app/ContentCenter/Publication.php
Normal file
104
laravel/app/ContentCenter/Publication.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\ContentCenter;
|
||||
|
||||
use App\Services\Ranking\SitePublisher;
|
||||
use App\Services\Ranking\WeeklyRanking;
|
||||
use RuntimeException;
|
||||
|
||||
final class Publication
|
||||
{
|
||||
public function publish(array $data): array
|
||||
{
|
||||
validator($data, ['approval_id' => 'required|uuid', 'approved_hash' => 'required|regex:/^[a-f0-9]{64}$/D', 'input' => 'required|array'])->validate();
|
||||
$artifact = app(ArticleArtifact::class)->build($data['input']);
|
||||
$hash = ArticleArtifact::hash($artifact);
|
||||
abort_unless(hash_equals($hash, $data['approved_hash']), 409, '待发稿已变化,必须重新审核。');
|
||||
$publisher = new SitePublisher(config('content_center.site_root'), config('content_center.node'));
|
||||
|
||||
return $publisher->locked(function () use ($data, $artifact, $hash, $publisher) {
|
||||
$directory = config('content_center.site_root').'/laravel/storage/app/private/content-center/publications';
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0700, true);
|
||||
}
|
||||
$receipt = $directory.'/'.hash('sha256', $artifact['source_key']).'.json';
|
||||
$previous = is_file($receipt) ? json_decode(file_get_contents($receipt), true, flags: JSON_THROW_ON_ERROR) : null;
|
||||
$byApproval = $directory.'/'.$data['approval_id'].'.json';
|
||||
$done = is_file($byApproval) ? json_decode(file_get_contents($byApproval), true, flags: JSON_THROW_ON_ERROR) : null;
|
||||
if ($done) {
|
||||
abort_unless(hash_equals($done['artifact_hash'], $hash), 409);
|
||||
if ($done['status'] === 'published') {
|
||||
return $done;
|
||||
}
|
||||
}
|
||||
abort_if($previous && $previous['generation'] > $artifact['generation'], 409, '较新版本已发布或正在发布。');
|
||||
if ($previous && $previous['approval_id'] === $data['approval_id']) {
|
||||
abort_unless(hash_equals($previous['artifact_hash'], $hash), 409);
|
||||
if ($previous['status'] === 'published') {
|
||||
return $previous;
|
||||
}
|
||||
}
|
||||
$path = $publisher->root.'/vuepress/docs/'.$artifact['relative_path'];
|
||||
if (is_file($path) && (! $previous || $previous['source_key'] !== $artifact['source_key'])) {
|
||||
throw new RuntimeException('文章编号冲突,未覆盖现有文章。');
|
||||
}
|
||||
$images = $publisher->root.'/vuepress/docs/.vuepress/public/content-assets';
|
||||
if (! is_dir($images)) {
|
||||
mkdir($images, 0755, true);
|
||||
}
|
||||
foreach ($artifact['images'] as $image) {
|
||||
$file = $images.'/'.$image['sha256'].ArticleArtifact::extension($image['mime']);
|
||||
if (is_file($file) && hash_file('sha256', $file) === $image['sha256']) {
|
||||
continue;
|
||||
}
|
||||
$response = app(ContentHttp::class)->request(config('content_center.atom_url'), config('content_center.atom_key'), 'GET', '/api/content/v1/assets/'.$image['sha256']);
|
||||
$bytes = $response->body();
|
||||
if (strlen($bytes) !== $image['bytes'] || strlen($bytes) > config('content_center.max_image_bytes') || hash('sha256', $bytes) !== $image['sha256'] || ! @getimagesizefromstring($bytes) || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== $image['mime']) {
|
||||
throw new RuntimeException('图片校验未通过。');
|
||||
}
|
||||
$this->atomic($file, $bytes, 0644);
|
||||
}
|
||||
$record = ['source_key' => $artifact['source_key'], 'approval_id' => $data['approval_id'], 'artifact_hash' => $hash, 'generation' => $artifact['generation'], 'permalink' => $artifact['permalink'], 'status' => 'building'];
|
||||
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);
|
||||
$old = is_file($path) ? file_get_contents($path) : null;
|
||||
try {
|
||||
$this->atomic($path, $artifact['markdown'], 0644);
|
||||
(new WeeklyRanking($publisher))->renderCurrent();
|
||||
$publisher->build();
|
||||
} catch (\Throwable) {
|
||||
if ($old === null) {
|
||||
@unlink($path);
|
||||
} else {
|
||||
$this->atomic($path, $old, 0644);
|
||||
}
|
||||
throw new RuntimeException('网站构建失败,原文章已恢复;按同一发布编号重试。');
|
||||
}
|
||||
$record['status'] = 'published';
|
||||
$record['published_at'] = now()->toIso8601String();
|
||||
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);
|
||||
$this->atomic($byApproval, json_encode($record, JSON_THROW_ON_ERROR), 0600);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
private function atomic(string $path, string $data, int $mode): void
|
||||
{
|
||||
if (! is_dir(dirname($path))) {
|
||||
mkdir(dirname($path), 0755, true);
|
||||
}
|
||||
$tmp = tempnam(dirname($path), '.content-');
|
||||
try {
|
||||
if (file_put_contents($tmp, $data) !== strlen($data)) {
|
||||
throw new RuntimeException('写入失败。');
|
||||
}chmod($tmp, $mode);
|
||||
if (! rename($tmp, $path)) {
|
||||
throw new RuntimeException('替换失败。');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tmp)) {
|
||||
unlink($tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,8 +9,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function () {
|
||||
\Illuminate\Support\Facades\Route::middleware('api')->group(base_path('content-center/routes.php'));
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->append(\App\ContentCenter\ProtectLegacyImport::class);
|
||||
$middleware->alias([
|
||||
//'test' => \App\Http\Middleware\Test::class,
|
||||
]);
|
||||
|
||||
17
laravel/config/content_center.php
Normal file
17
laravel/config/content_center.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'environment' => env('CONTENT_ENVIRONMENT', 'local'),
|
||||
'enabled' => (bool) env('CONTENT_CENTER_ENABLED', false),
|
||||
'telefox_url' => env('CONTENT_TELEFOX_URL', ''),
|
||||
'telefox_token' => env('CONTENT_TELEFOX_TOKEN', ''),
|
||||
'ttc_url' => env('CONTENT_TTC_URL', ''),
|
||||
'ttc_key' => env('CONTENT_TTC_KEY', ''),
|
||||
'ai_key' => env('CONTENT_AI_KEY', ''),
|
||||
'atom_url' => env('CONTENT_ATOM_URL', ''),
|
||||
'atom_key' => env('CONTENT_ATOM_KEY', ''),
|
||||
'max_image_bytes' => 20971520,
|
||||
'asset_directory' => storage_path('app/private/content-center/assets'),
|
||||
'site_root' => dirname(base_path()),
|
||||
'node' => env('vue_path_node', 'node'),
|
||||
];
|
||||
10
laravel/content-center/routes.php
Normal file
10
laravel/content-center/routes.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\ContentCenter\ContentAuthenticate;
|
||||
use App\ContentCenter\ContentController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('api/content/v1')->middleware([ContentAuthenticate::class.':atom', 'throttle:30,1'])->group(function () {
|
||||
Route::post('/preview', [ContentController::class, 'preview']);
|
||||
Route::post('/publish', [ContentController::class, 'publish']);
|
||||
});
|
||||
109
laravel/tests/Feature/ContentCenterTest.php
Normal file
109
laravel/tests/Feature/ContentCenterTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\ContentCenter\ArticleArtifact;
|
||||
use App\ContentCenter\Publication;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ContentCenterTest extends TestCase
|
||||
{
|
||||
public function createApplication()
|
||||
{
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
\Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc');
|
||||
Http::preventStrayRequests();
|
||||
$this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid();
|
||||
File::makeDirectory($this->root.'/vuepress/node_modules/.bin', 0755, true);
|
||||
File::makeDirectory($this->root.'/vuepress/docs/jccp', 0755, true);
|
||||
File::makeDirectory($this->root.'/laravel/storage/app/weekly-ranking', 0755, true);
|
||||
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php $dest=$argv[array_search("--dest",$argv)+1];mkdir($dest,0755,true);file_put_contents($dest."/index.html","isolated test build");');
|
||||
config(['content_center.environment' => 'local', 'content_center.enabled' => true, 'content_center.atom_key' => str_repeat('k', 40), 'content_center.atom_url' => 'http://atom.loc', 'content_center.site_root' => $this->root, 'content_center.node' => PHP_BINARY, 'cache.default' => 'array']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory($this->root);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function input(int $generation = 1): array
|
||||
{
|
||||
return ['source_key' => 'telegram:-1001234567890:album:99', 'revision' => str_repeat('a', 64), 'generation' => $generation, 'title' => '测评标题', 'text' => '<script>bad()</script> {{ danger }}', 'sent_at' => '2026-10-24 08:01:01', 'images' => []];
|
||||
}
|
||||
|
||||
private function signed(string $path, array $data, ?string $nonce = null)
|
||||
{
|
||||
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$time = (string) time();
|
||||
$nonce ??= bin2hex(random_bytes(16));
|
||||
$sig = hash_hmac('sha256', $time."\n".$nonce."\nPOST\n".$path."\n".hash('sha256', $body), str_repeat('k', 40));
|
||||
|
||||
return $this->call('POST', $path, [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_CONTENT_TIME' => $time, 'HTTP_X_CONTENT_NONCE' => $nonce, 'HTTP_X_CONTENT_ENVIRONMENT' => 'local', 'HTTP_X_CONTENT_SIGNATURE' => $sig], $body);
|
||||
}
|
||||
|
||||
public function test_preview_authenticates_and_does_not_write_or_build(): void
|
||||
{
|
||||
$this->postJson('/api/content/v1/preview', $this->input())->assertUnauthorized();
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$response = $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertOk();
|
||||
$markdown = $response->json('artifact.markdown');
|
||||
$this->assertStringContainsString('<script>', $markdown);
|
||||
$this->assertStringContainsString('<div v-pre>', $markdown);
|
||||
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
|
||||
$this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertStatus(409);
|
||||
}
|
||||
|
||||
public function test_legacy_receive_cannot_bypass_content_review(): void
|
||||
{
|
||||
$this->postJson('/api/article/receive/schema', ['cmd' => 'new'])->assertStatus(410);
|
||||
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
|
||||
}
|
||||
|
||||
public function test_wrong_hash_cannot_publish(): void
|
||||
{
|
||||
$this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => str_repeat('0', 64), 'input' => $this->input()])->assertStatus(409);
|
||||
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
|
||||
}
|
||||
|
||||
public function test_approved_publication_is_idempotent_and_old_generation_cannot_overwrite_new(): void
|
||||
{
|
||||
$input = $this->input(20);
|
||||
$artifact = app(ArticleArtifact::class)->build($input);
|
||||
$payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input];
|
||||
$one = $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.status', 'published');
|
||||
$this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.approval_id', $payload['approval_id']);
|
||||
$this->assertSame(1, count(File::files($this->root.'/vuepress/docs/jccp')));
|
||||
$old = $this->input(10);
|
||||
$this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash(app(ArticleArtifact::class)->build($old)), 'input' => $old])->assertStatus(409);
|
||||
}
|
||||
|
||||
public function test_failed_build_restores_source_and_preserves_previous_dist(): void
|
||||
{
|
||||
File::makeDirectory($this->root.'/vuepress/docs/.vuepress/dist', 0755, true);
|
||||
file_put_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html', 'previous');
|
||||
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php exit(1);');
|
||||
$input = $this->input();
|
||||
$artifact = app(ArticleArtifact::class)->build($input);
|
||||
try {
|
||||
app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]);
|
||||
$this->fail('Expected build failure');
|
||||
} catch (\RuntimeException) {
|
||||
$this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']);
|
||||
$this->assertSame('previous', file_get_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html'));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user