set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); }); it('queues the sync instead of running it inside the request', function () { Queue::fake(); Livewire::actingAs(operator('Owner'), 'operator') ->test(Integrations::class) ->call('syncCatalogue', true) ->assertHasNoErrors(); Queue::assertPushed(RunCatalogueSync::class, fn ($job) => $job->dryRun === true); }); it('confirms the live run in a modal, and only the live run', function () { // Der Trockenlauf ändert nichts und braucht keine Rückfrage — genau // deshalb ist er der erste Knopf. Der echte Lauf legt Objekte in einem // Zahlungskonto an (R23). Queue::fake(); Livewire::actingAs(operator('Owner'), 'operator') ->test(ConfirmSyncCatalogue::class) ->call('confirm') ->assertDispatched('catalogue-sync-confirmed'); }); it('records what the command printed, word for word', function () { // Der Trockenlauf zählt, was FEHLEN würde. Sein Text ist das, was der // Betreiber sonst im Terminal gelesen hätte — gekürzt oder umformuliert // wäre er eine zweite Quelle für dieselbe Aussage. (new RunCatalogueSync(dryRun: true, by: 'chef@example.test'))->handle(); $last = app(CatalogueSyncStatus::class)->last(); expect($last)->not->toBeNull() ->and($last['dry_run'])->toBeTrue() ->and($last['by'])->toBe('chef@example.test') // Die Zeilen, die der Betreiber sonst im Terminal gelesen hätte: was // angelegt WÜRDE, Zeile für Zeile, plus die Summe darunter. ->and($last['output'])->toContain('would be created') ->and($last['output'])->toContain('price start v1 monthly'); }); it('shows the stale-catalogue refusal verbatim rather than a summary of it', function () { // Die teure Meldung: sie warnt davor, einen Katalog zu leeren, an dem // laufende Verträge hängen. Wer sie zu „Abgleich fehlgeschlagen" // zusammenfasst, nimmt dem Betreiber genau die Anweisung weg, die er // braucht — und die Alternative ist, sie doch wieder im Terminal zu suchen. // Die Migration legt den Grundkatalog selbst an — eine dieser Familien // trägt jetzt eine ID aus dem anderen Konto. PlanFamily::query()->firstOrFail()->update(['stripe_product_id' => 'prod_from_the_other_account']); StripeCatalogueMode::record(OperatingMode::current()->isTest() ? OperatingMode::Live : OperatingMode::Test); (new RunCatalogueSync(dryRun: true, by: 'chef@example.test'))->handle(); $last = app(CatalogueSyncStatus::class)->last(); expect($last['ok'])->toBeFalse() ->and($last['output'])->toContain('plan_families.stripe_product_id') ->and($last['output'])->toContain('Nothing was created.'); }); it('puts the last result on the page', function () { app(CatalogueSyncStatus::class)->record(ok: true, dryRun: true, output: 'MARKER-42 object(s) would be created', by: 'chef@example.test'); Livewire::actingAs(operator('Owner'), 'operator') ->test(Integrations::class) ->assertSee('MARKER-42 object(s) would be created'); }); it('does not let an operator without the catalogue capability start a run', function () { // Am Modal geprüft, nicht an der Seite: ein Modal ist OHNE die // Route-Middleware der Seite erreichbar (siehe EditPlanFamily), also ist // das hier die Grenze, die wirklich hält. Die Seite selbst weist Support // schon in mount() ab — das ist der Test darunter. Queue::fake(); Livewire::actingAs(operator('Support'), 'operator') ->test(ConfirmSyncCatalogue::class) ->assertForbidden(); Queue::assertNothingPushed(); }); it('does not even show the page to an operator who cannot reach it', function () { Livewire::actingAs(operator('Support'), 'operator') ->test(Integrations::class) ->assertForbidden(); });