feat: add CTS API request logging with searchable frontend UI
This commit is contained in:
parent
78ea9459c2
commit
85111c70e7
BIN
.sisyphus/evidence/task-6-api-log-filter.png
Normal file
BIN
.sisyphus/evidence/task-6-api-log-filter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
.sisyphus/evidence/task-6-api-log-nav.png
Normal file
BIN
.sisyphus/evidence/task-6-api-log-nav.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
.sisyphus/evidence/task-6-api-log-page.png
Normal file
BIN
.sisyphus/evidence/task-6-api-log-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
245
.sisyphus/evidence/task-6-migration-tests.txt
Normal file
245
.sisyphus/evidence/task-6-migration-tests.txt
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
|
||||
INFO Nothing to migrate.
|
||||
|
||||
|
||||
PASS Tests\Unit\ExampleTest
|
||||
✓ that true is true
|
||||
|
||||
PASS Tests\Feature\ApiLogControllerTest
|
||||
✓ api log index zeigt die api logs seite mit paginated logs 0.20s
|
||||
✓ api log index filtert nach suche 0.01s
|
||||
✓ api log index filtert nach status 0.01s
|
||||
✓ api request log scopes funktionieren 0.01s
|
||||
|
||||
PASS Tests\Feature\ArrangementControllerTest
|
||||
✓ create arrangement clones groups from default arrangement 0.02s
|
||||
✓ clone arrangement duplicates current arrangement groups 0.01s
|
||||
✓ update arrangement reorders and persists groups 0.02s
|
||||
✓ cannot delete the last arrangement of a song 0.01s
|
||||
|
||||
PASS Tests\Feature\ChurchToolsSyncTest
|
||||
✓ cts:sync synchronisiert services, agenda songs und schreibt sync lo… 0.02s
|
||||
|
||||
PASS Tests\Feature\CtsApiSpikeTest
|
||||
✓ it syncs mocked future events and song shape through the CTS pipeli… 0.02s
|
||||
✓ it returns auth blocker when API token is missing
|
||||
|
||||
PASS Tests\Feature\DatabaseSchemaTest
|
||||
✓ all expected database tables exist 0.01s
|
||||
✓ all factories create valid records 0.01s
|
||||
|
||||
PASS Tests\Feature\ExampleTest
|
||||
✓ example 0.01s
|
||||
|
||||
PASS Tests\Feature\FileConversionTest
|
||||
✓ convert image creates 1920x1080 jpg with black bars and thumbnail 0.13s
|
||||
✓ portrait image gets pillarbox bars on left and right 0.18s
|
||||
|
||||
PASS Tests\Feature\FinalizationTest
|
||||
✓ finalize ohne voraussetzungen gibt warnungen zurueck 0.01s
|
||||
✓ finalize mit confirmed=true trotz warnungen finalisiert service 0.01s
|
||||
✓ finalize ohne warnungen finalisiert direkt 0.01s
|
||||
✓ finalize warnt bei fehlenden song-zuordnungen 0.01s
|
||||
✓ finalize warnt bei fehlenden predigtfolien 0.01s
|
||||
✓ reopen setzt finalized_at zurueck 0.01s
|
||||
✓ download gibt placeholder nachricht zurueck 0.01s
|
||||
✓ finalize erfordert authentifizierung 0.01s
|
||||
✓ download erfordert authentifizierung 0.01s
|
||||
✓ service model isReadyToFinalize accessor 0.01s
|
||||
✓ finalization status mit service ohne songs warnt nur bei predigtfol… 0.01s
|
||||
|
||||
PASS Tests\Feature\HomeTest
|
||||
✓ home route redirects unauthenticated users to login 0.01s
|
||||
✓ home route redirects authenticated users to dashboard 0.01s
|
||||
|
||||
PASS Tests\Feature\InformationBlockTest
|
||||
✓ information slides shown dynamically by expire date 0.01s
|
||||
✓ information slides expire on service date are still shown 0.01s
|
||||
✓ information slides are global and appear in all services where not… 0.02s
|
||||
✓ soft deleted information slides are not shown 0.01s
|
||||
✓ information slides do not include moderation or sermon slides 0.01s
|
||||
✓ information slides without expire_date are not shown 0.01s
|
||||
✓ information slides ordered by uploaded_at descending 0.01s
|
||||
|
||||
PASS Tests\Feature\MissingSongMailTest
|
||||
✓ missing song request mailable renders with german content 0.02s
|
||||
✓ missing song request mailable has correct subject 0.01s
|
||||
|
||||
PASS Tests\Feature\ModerationBlockTest
|
||||
✓ moderation slides are service-specific 0.01s
|
||||
✓ moderation slides do not include information slides 0.01s
|
||||
✓ moderation slides require service_id 0.01s
|
||||
✓ moderation block filters slides correctly 0.01s
|
||||
✓ moderation slides do not have expire_date field 0.01s
|
||||
|
||||
PASS Tests\Feature\OAuthTest
|
||||
✓ it redirects unauthenticated users to login 0.01s
|
||||
✓ it shows login page with OAuth button 0.01s
|
||||
✓ it login page has no email or password inputs 0.01s
|
||||
✓ it redirects to ChurchTools OAuth on auth initiation 0.01s
|
||||
✓ it creates a new user from OAuth callback 0.01s
|
||||
✓ it updates existing user on OAuth callback 0.01s
|
||||
✓ it logs out user and redirects to login 0.01s
|
||||
✓ it does not have register routes 0.01s
|
||||
✓ it authenticated user can access dashboard 0.01s
|
||||
|
||||
PASS Tests\Feature\ProPlaceholderTest
|
||||
✓ Pro File Placeholder Endpoints → POST /api/songs/import-pro → it re… 0.01s
|
||||
✓ Pro File Placeholder Endpoints → POST /api/songs/import-pro → it re… 0.01s
|
||||
✓ Pro File Placeholder Endpoints → GET /api/songs/{song}/download-pro… 0.01s
|
||||
✓ Pro File Placeholder Endpoints → GET /api/songs/{song}/download-pro… 0.01s
|
||||
✓ Pro File Placeholder Endpoints → GET /api/songs/{song}/download-pro… 0.01s
|
||||
|
||||
PASS Tests\Feature\SermonBlockTest
|
||||
✓ sermon slides are service-specific 0.01s
|
||||
✓ sermon slides do not include information slides 0.01s
|
||||
✓ sermon slides require service_id 0.01s
|
||||
✓ sermon block filters slides correctly 0.01s
|
||||
✓ sermon slides do not have expire_date field 0.01s
|
||||
|
||||
PASS Tests\Feature\ServiceControllerTest
|
||||
✓ services index zeigt nur heutige und kuenftige services mit statusd… 0.01s
|
||||
✓ service kann abgeschlossen werden 0.01s
|
||||
✓ service kann wieder geoeffnet werden 0.01s
|
||||
✓ service edit seite zeigt service mit songs und slides 0.01s
|
||||
✓ service edit erfordert authentifizierung 0.01s
|
||||
✓ services index zeigt nur zukuenftige services standardmaessig 0.01s
|
||||
✓ services index zeigt vergangene services mit archived parameter 0.01s
|
||||
|
||||
PASS Tests\Feature\SharedPropsTest
|
||||
✓ shared props include auth user with expected fields when authentica… 0.01s
|
||||
✓ shared props include null auth user when not logged in 0.01s
|
||||
✓ shared props include flash success message 0.01s
|
||||
✓ shared props include flash error message 0.01s
|
||||
✓ shared props include last_synced_at from latest sync log 0.01s
|
||||
✓ shared props include null last_synced_at when no sync log exists 0.01s
|
||||
✓ shared props include app_name from config 0.02s
|
||||
|
||||
PASS Tests\Feature\SlideControllerTest
|
||||
✓ upload image creates slide with 1920x1080 jpg 0.12s
|
||||
✓ upload image with expire_date stores date on slide 0.08s
|
||||
✓ upload moderation slide without service_id fails 0.01s
|
||||
✓ upload information slide without service_id is allowed 0.08s
|
||||
✓ upload rejects unsupported file types 0.01s
|
||||
✓ upload rejects invalid type 0.02s
|
||||
✓ upload pptx dispatches conversion job 0.01s
|
||||
✓ upload zip processes contained images 0.08s
|
||||
✓ unauthenticated user cannot upload slides 0.01s
|
||||
✓ delete slide soft deletes it 0.01s
|
||||
✓ delete non-existing slide returns 404 0.01s
|
||||
✓ update expire date on information slide 0.01s
|
||||
✓ update expire date rejects non-information slides 0.01s
|
||||
✓ expire date must be a valid date 0.01s
|
||||
✓ expire date can be set to null 0.01s
|
||||
|
||||
PASS Tests\Feature\SongControllerTest
|
||||
✓ songs index returns paginated list 0.01s
|
||||
✓ songs index excludes soft-deleted songs 0.01s
|
||||
✓ songs index search by title 0.01s
|
||||
✓ songs index search by ccli id 0.01s
|
||||
✓ songs index requires authentication 0.01s
|
||||
✓ store creates song with default groups and arrangement 0.01s
|
||||
✓ store validates required title 0.01s
|
||||
✓ store validates unique ccli_id 0.01s
|
||||
✓ store allows null ccli_id 0.01s
|
||||
✓ show returns song with groups slides and arrangements 0.01s
|
||||
✓ show returns 404 for nonexistent song 0.01s
|
||||
✓ show returns 404 for soft-deleted song 0.01s
|
||||
✓ update modifies song metadata 0.01s
|
||||
✓ update validates unique ccli_id excluding self 0.01s
|
||||
✓ update allows keeping own ccli_id 0.01s
|
||||
✓ destroy soft-deletes a song 0.01s
|
||||
✓ destroy returns 404 for nonexistent song 0.01s
|
||||
✓ last_used_in_service returns correct date from service_songs 0.01s
|
||||
✓ last_used_in_service returns null when never used 0.01s
|
||||
✓ duplicate arrangement clones arrangement with groups 0.01s
|
||||
|
||||
PASS Tests\Feature\SongEditModalTest
|
||||
✓ show returns song with full detail for modal 0.02s
|
||||
✓ update saves title via auto-save 0.01s
|
||||
✓ update saves ccli_id via auto-save 0.01s
|
||||
✓ update saves copyright_text via auto-save 0.01s
|
||||
✓ update can clear optional fields with null 0.01s
|
||||
✓ update returns full song detail with arrangements 0.01s
|
||||
✓ update validates title is required 0.01s
|
||||
✓ update validates unique ccli_id against other songs 0.01s
|
||||
✓ update requires authentication 0.01s
|
||||
✓ show returns 404 for soft-deleted song 0.01s
|
||||
✓ update returns 404 for nonexistent song 0.01s
|
||||
|
||||
PASS Tests\Feature\SongIndexTest
|
||||
✓ songs index page renders for authenticated users 0.01s
|
||||
✓ songs index page redirects unauthenticated users to login 0.01s
|
||||
✓ songs index route is named songs.index 0.01s
|
||||
✓ songs api returns data for songs page 0.01s
|
||||
✓ songs api search filters by title 0.01s
|
||||
✓ songs api search filters by ccli id 0.01s
|
||||
✓ songs api does not return soft-deleted songs 0.01s
|
||||
✓ songs api paginates results 0.01s
|
||||
✓ songs api delete soft-deletes a song 0.01s
|
||||
|
||||
PASS Tests\Feature\SongMatchingTest
|
||||
✓ autoMatch ordnet Song per CCLI-ID zu 0.01s
|
||||
✓ autoMatch gibt false zurück wenn kein CCLI-ID vorhanden 0.01s
|
||||
✓ autoMatch gibt false zurück wenn kein passender Song in DB 0.01s
|
||||
✓ autoMatch überspringt bereits zugeordnete Songs 0.01s
|
||||
✓ manualAssign ordnet Song manuell zu 0.01s
|
||||
✓ manualAssign überschreibt bestehende Zuordnung 0.01s
|
||||
✓ requestCreation sendet E-Mail und setzt request_sent_at 0.01s
|
||||
✓ unassign entfernt Zuordnung 0.01s
|
||||
✓ POST /api/service-songs/{id}/assign ordnet Song zu 0.01s
|
||||
✓ POST /api/service-songs/{id}/assign validiert song_id 0.01s
|
||||
✓ POST /api/service-songs/{id}/request sendet Anfrage-E-Mail 0.01s
|
||||
✓ POST /api/service-songs/{id}/unassign entfernt Zuordnung 0.01s
|
||||
✓ API Endpunkte erfordern Authentifizierung 0.01s
|
||||
✓ API gibt 404 für nicht existierende ServiceSong 0.01s
|
||||
|
||||
PASS Tests\Feature\SongPdfTest
|
||||
✓ song pdf download returns pdf with correct content type 0.21s
|
||||
✓ song pdf contains song title in filename 0.13s
|
||||
✓ song pdf includes arrangement groups in order 0.18s
|
||||
✓ song pdf includes translated text when present 0.18s
|
||||
✓ song pdf includes copyright footer 0.13s
|
||||
✓ song pdf returns 404 when arrangement does not belong to song 0.01s
|
||||
✓ song pdf requires authentication 0.01s
|
||||
✓ song pdf handles german umlauts correctly 0.18s
|
||||
✓ song pdf works with empty arrangement (no groups) 0.13s
|
||||
✓ song preview returns json with groups in arrangement order 0.01s
|
||||
✓ song preview includes translation text when slides have translation… 0.01s
|
||||
✓ song preview returns 404 when arrangement does not belong to song 0.01s
|
||||
✓ song preview requires authentication 0.01s
|
||||
|
||||
PASS Tests\Feature\SongsBlockTest
|
||||
✓ songs block shows unmatched song with matching options 0.02s
|
||||
✓ songs block provides matched song data for arrangement configurator… 0.01s
|
||||
|
||||
PASS Tests\Feature\SyncControllerTest
|
||||
✓ sync controller propagiert Fehlermeldung bei Sync-Fehler 0.01s
|
||||
✓ sync controller zeigt Erfolgsmeldung bei erfolgreichem Sync 0.01s
|
||||
|
||||
PASS Tests\Feature\TranslatePageTest
|
||||
✓ translate page response contains ordered groups and slides 0.01s
|
||||
|
||||
PASS Tests\Feature\TranslationServiceTest
|
||||
✓ fetchFromUrl returns text from successful HTTP response 0.02s
|
||||
✓ fetchFromUrl returns null on HTTP failure 0.01s
|
||||
✓ fetchFromUrl returns null on connection error 0.01s
|
||||
✓ fetchFromUrl returns null for empty response body 0.01s
|
||||
✓ importTranslation distributes lines by slide line counts 0.01s
|
||||
✓ importTranslation distributes across multiple groups 0.01s
|
||||
✓ importTranslation handles fewer translation lines than original 0.01s
|
||||
✓ importTranslation marks song as translated 0.01s
|
||||
✓ markAsTranslated sets has_translation to true 0.01s
|
||||
✓ removeTranslation clears all translated text and sets flag to false 0.01s
|
||||
✓ POST translation/fetch-url returns scraped text 0.01s
|
||||
✓ POST translation/fetch-url returns error on failure 0.01s
|
||||
✓ POST translation/fetch-url validates url field 0.01s
|
||||
✓ POST songs/{song}/translation/import distributes and saves translat… 0.01s
|
||||
✓ POST songs/{song}/translation/import validates text field 0.01s
|
||||
✓ POST songs/{song}/translation/import returns 404 for missing song 0.01s
|
||||
✓ DELETE songs/{song}/translation removes translation 0.01s
|
||||
✓ translation endpoints require authentication 0.01s
|
||||
|
||||
Tests: 182 passed (997 assertions)
|
||||
Duration: 3.71s
|
||||
|
||||
172
.sisyphus/notepads/cts-bugfix-features/learnings.md
Normal file
172
.sisyphus/notepads/cts-bugfix-features/learnings.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Task 2: Wire SermonBlock in Edit.vue - Learnings
|
||||
|
||||
## Problem
|
||||
The SermonBlock component existed at `resources/js/Components/Blocks/SermonBlock.vue` but was not imported or rendered in the Services/Edit.vue page. Additionally, the `refreshPage` function was called on slide upload events but didn't exist, causing silent failures.
|
||||
|
||||
## Solution
|
||||
Made 3 atomic changes to `resources/js/Pages/Services/Edit.vue`:
|
||||
|
||||
1. **Import SermonBlock** (Line 8)
|
||||
- Added: `import SermonBlock from '@/Components/Blocks/SermonBlock.vue'`
|
||||
- Placed after ModerationBlock import to follow existing pattern
|
||||
|
||||
2. **Add refreshPage function** (Lines 63-65)
|
||||
- Added: `function refreshPage() { router.reload({ preserveScroll: true }) }`
|
||||
- Uses Inertia's router.reload() to refresh page while preserving scroll position
|
||||
- Called by @slides-updated event from all block components
|
||||
|
||||
3. **Render SermonBlock in template** (Lines 269-274)
|
||||
- Added v-else-if block between ModerationBlock and SongsBlock
|
||||
- Props: `:service-id="service.id"` and `:slides="sermonSlides"`
|
||||
- Event: `@slides-updated="refreshPage"`
|
||||
|
||||
## Key Findings
|
||||
- SermonBlock.vue was fully implemented (76 lines) with SlideUploader and SlideGrid components
|
||||
- The component properly filters slides by type and service_id
|
||||
- Props: serviceId (Number, required), slides (Array, default [])
|
||||
- Emits: slides-updated event on upload, delete, or update
|
||||
|
||||
## Verification
|
||||
- ✅ Build succeeds (npm run build)
|
||||
- ✅ All SermonBlock and ServiceController tests pass (12 tests)
|
||||
- ✅ Sermon block renders correctly with uploader and grid (not placeholder)
|
||||
- ✅ No LSP diagnostics errors
|
||||
- ✅ Screenshots saved as evidence
|
||||
|
||||
## Bonus Fix
|
||||
Fixed pre-existing syntax error in ServiceController.php:
|
||||
- Line 21 had duplicate opening brace `{` that prevented the services index from loading
|
||||
- Removed the extra brace to fix PHP parse error
|
||||
|
||||
## Commits
|
||||
1. `292ad6b` - fix: wire SermonBlock in Edit.vue and add missing refreshPage function
|
||||
2. `5459529` - fix: remove duplicate opening brace in ServiceController index method
|
||||
|
||||
## Task 3: Sync Error Message Propagation (2026-03-02)
|
||||
|
||||
### Problem
|
||||
- SyncController only checked Artisan exit code (0 vs non-zero)
|
||||
- Actual error messages from ChurchToolsService were lost
|
||||
- Users saw generic "Fehler beim Synchronisieren" with no diagnostic info
|
||||
- Real error: "Agenda for event [823] not found." was swallowed
|
||||
|
||||
### Solution
|
||||
- Replaced `Artisan::call('cts:sync')` with direct `ChurchToolsService::sync()` call
|
||||
- Injected ChurchToolsService via method parameter (Laravel auto-resolves)
|
||||
- Wrapped in try/catch to capture actual exception message
|
||||
- On error: `back()->with('error', 'Sync fehlgeschlagen: ' . $e->getMessage())`
|
||||
- On success: kept existing success message
|
||||
|
||||
### Pattern: Direct Service Call vs Artisan
|
||||
**PREFER**: Direct service injection for web controllers
|
||||
- Better error handling (catch actual exceptions)
|
||||
- Better testability (mock service easily)
|
||||
- No need to parse console output
|
||||
- Clearer dependency graph
|
||||
|
||||
**USE ARTISAN**: Only for scheduled tasks, CLI operations, or when you need console output formatting
|
||||
|
||||
### Testing Pattern
|
||||
- Created SyncControllerTest.php with Mockery mocks
|
||||
- Mocked ChurchToolsService to throw exception
|
||||
- Verified error message propagates to session flash
|
||||
- Required authentication: `$this->actingAs($user)`
|
||||
- All 178 tests pass (2 new tests added)
|
||||
|
||||
### Files Modified
|
||||
- `app/Http/Controllers/SyncController.php` - replaced Artisan::call with direct service call
|
||||
- `tests/Feature/SyncControllerTest.php` - new test file with error propagation tests
|
||||
|
||||
### Actual Error Found
|
||||
Running `php artisan cts:sync` revealed: "Agenda for event [823] not found."
|
||||
This is now properly surfaced to users instead of generic error message.
|
||||
|
||||
## Task 4: Archived Services Toggle
|
||||
|
||||
**Implementation:**
|
||||
- Backend: Modified `ServiceController::index()` to accept `archived` query param
|
||||
- `archived=1` filters services with `date < today` ordered descending
|
||||
- Default (no param or `archived=0`) shows `date >= today` ordered ascending
|
||||
- Passed `archived` boolean to frontend via Inertia
|
||||
- Frontend: Added pill-style toggle in header with "Kommende" / "Vergangene" labels
|
||||
- Active state shown with blue background (`bg-blue-600 text-white`)
|
||||
- Inactive state shown with gray (`text-gray-700 hover:bg-gray-100`)
|
||||
- Click triggers `router.get()` with `archived` param
|
||||
- Empty state text changes conditionally based on archived state
|
||||
- Header description updates based on archived state
|
||||
|
||||
**Testing:**
|
||||
- Added two new Pest tests in `ServiceControllerTest.php`:
|
||||
- `test_services_index_zeigt_nur_zukuenftige_services_standardmaessig`
|
||||
- `test_services_index_zeigt_vergangene_services_mit_archived_parameter`
|
||||
- All 176 tests pass (2 pre-existing failures unrelated to this task)
|
||||
- Playwright verification confirmed toggle works correctly in browser
|
||||
|
||||
**Patterns:**
|
||||
- Inertia router preserves state/scroll with `preserveState: true, preserveScroll: true`
|
||||
- Conditional rendering in Vue using ternary operators for text content
|
||||
- Dynamic class binding with array syntax for active/inactive states
|
||||
- Backend query conditional logic using if/else for different filters
|
||||
|
||||
**Evidence:**
|
||||
- Screenshot: `.sisyphus/evidence/task-4-archived-toggle.png`
|
||||
- Commit: `8dc26b8` - "feat: add archived services toggle to services list"
|
||||
|
||||
## Task 6: CTS API Request Logging + UI (2026-03-02)
|
||||
|
||||
### Backend Pattern
|
||||
- Zentrale Logging-Helfermethode in `ChurchToolsService` (`logApiCall`) kapselt Timing, Erfolg/Fehler und Exception-Re-throw.
|
||||
- So bleiben Fachmethoden (`fetchEvents`, `fetchSongs`, `syncAgenda`, `getEventServices`) lesbar und Logging ist konsistent.
|
||||
- `response_summary` sollte kurz bleiben (z. B. "Array mit X Eintraegen"), um DB-Eintraege klein und schnell durchsuchbar zu halten.
|
||||
|
||||
### Datenmodell/Filter
|
||||
- Tabelle `api_request_logs` mit `status` + `created_at` Indexen reicht fuer schnelle Standardfilter (Status + Neueste zuerst).
|
||||
- Eloquent-Scopes `byStatus()` und `search()` halten Controller schlank und wiederverwendbar.
|
||||
- `search()` ueber `method`, `endpoint`, `error_message` deckt die wichtigsten Debug-Faelle ab.
|
||||
|
||||
### Frontend/Inertia Pattern
|
||||
- Debounced Suche (300ms) mit `router.get(..., { replace: true, preserveState: true })` verhindert History-Spam.
|
||||
- Fehlerzeilen visuell hervorheben (`bg-red-50`) + rote Status-Badges verbessert Scanbarkeit deutlich.
|
||||
- Laravel-Pagination kann direkt als `logs.links` in Vue gerendert werden (`Link` + `withQueryString()`).
|
||||
|
||||
### QA/Verification
|
||||
- Nach Klick auf "Daten aktualisieren" erscheinen sofort neue API-Log-Eintraege inkl. Fehlerdetails (z. B. Agenda not found).
|
||||
- Pflicht-Evidenz fuer Task 6:
|
||||
- `.sisyphus/evidence/task-6-api-log-page.png`
|
||||
- `.sisyphus/evidence/task-6-api-log-filter.png`
|
||||
- `.sisyphus/evidence/task-6-api-log-nav.png`
|
||||
- `.sisyphus/evidence/task-6-migration-tests.txt`
|
||||
|
||||
|
||||
## Task 5: Reposition Upload Area to Right of Slides Grid
|
||||
|
||||
**Layout Pattern:**
|
||||
- Used `flex flex-col lg:flex-row-reverse gap-6` wrapper around SlideUploader + SlideGrid
|
||||
- `flex-row-reverse` keeps HTML order (uploader first, grid second) but visually flips on desktop
|
||||
- Mobile (`flex-col`): uploader on top, grid below
|
||||
- Desktop (`lg:flex-row-reverse`): grid left (~70%), uploader right (~30%)
|
||||
- Uploader wrapper: `lg:w-1/3`
|
||||
- Grid wrapper: `flex-1 lg:w-2/3`
|
||||
|
||||
**SlideUploader CSS Changes:**
|
||||
- Reduced `.v3-dropzone` min-height: 160px → 120px
|
||||
- Reduced `.v3-dropzone` padding: `2rem 1.5rem` → `1.5rem 1rem`
|
||||
- These make the dropzone more compact in the narrower column
|
||||
|
||||
**Gotcha:**
|
||||
- Edit tool can merge closing `</div>` tags when replacement ends with `</div>` and the next existing line is also `</div>`
|
||||
- Always verify HTML structure after edits by checking the build passes
|
||||
- The build error "Element is missing end tag" immediately reveals unbalanced tags
|
||||
|
||||
**Files Modified:**
|
||||
- `resources/js/Components/Blocks/InformationBlock.vue` - flex wrapper
|
||||
- `resources/js/Components/Blocks/ModerationBlock.vue` - flex wrapper
|
||||
- `resources/js/Components/Blocks/SermonBlock.vue` - flex wrapper
|
||||
- `resources/js/Components/SlideUploader.vue` - reduced dropzone size
|
||||
|
||||
**Verification:**
|
||||
- ✅ Build passes (npm run build)
|
||||
- ✅ All 178 tests pass
|
||||
- ✅ Desktop screenshot: grid left, uploader right, all three blocks identical
|
||||
- ✅ Mobile screenshot: stacked vertically, uploader on top
|
||||
- ✅ No LSP diagnostics errors
|
||||
40
app/Http/Controllers/ApiLogController.php
Normal file
40
app/Http/Controllers/ApiLogController.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ApiRequestLog;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ApiLogController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$search = request()->string('search')->toString();
|
||||
$status = request()->string('status')->toString();
|
||||
|
||||
$logs = ApiRequestLog::query()
|
||||
->search($search)
|
||||
->byStatus($status)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25)
|
||||
->withQueryString()
|
||||
->through(fn (ApiRequestLog $log) => [
|
||||
'id' => $log->id,
|
||||
'created_at' => $log->created_at?->toJSON(),
|
||||
'method' => $log->method,
|
||||
'endpoint' => $log->endpoint,
|
||||
'status' => $log->status,
|
||||
'duration_ms' => $log->duration_ms,
|
||||
'error_message' => $log->error_message,
|
||||
]);
|
||||
|
||||
return Inertia::render('ApiLogs/Index', [
|
||||
'logs' => $logs,
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'status' => $status,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
63
app/Models/ApiRequestLog.php
Normal file
63
app/Models/ApiRequestLog.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApiRequestLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'method',
|
||||
'endpoint',
|
||||
'status',
|
||||
'request_context',
|
||||
'response_summary',
|
||||
'error_message',
|
||||
'duration_ms',
|
||||
'sync_log_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'request_context' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function syncLog(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CtsSyncLog::class, 'sync_log_id');
|
||||
}
|
||||
|
||||
public function scopeByStatus(Builder $query, ?string $status): Builder
|
||||
{
|
||||
if ($status === null || $status === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, ?string $search): Builder
|
||||
{
|
||||
if ($search === null || trim($search) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$term = '%' . trim($search) . '%';
|
||||
|
||||
return $query->where(function (Builder $builder) use ($term): void {
|
||||
$builder
|
||||
->where('method', 'like', $term)
|
||||
->orWhere('endpoint', 'like', $term)
|
||||
->orWhere('error_message', 'like', $term);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ApiRequestLog;
|
||||
use CTApi\CTConfig;
|
||||
use CTApi\Models\Events\Event\EventAgendaRequest;
|
||||
use CTApi\Models\Events\Event\EventRequest;
|
||||
|
|
@ -124,7 +125,12 @@ public function syncAgenda(int $eventId): mixed
|
|||
return EventAgendaRequest::fromEvent($id)->get();
|
||||
};
|
||||
|
||||
return $fetcher($eventId);
|
||||
return $this->logApiCall(
|
||||
'syncAgenda',
|
||||
'agenda',
|
||||
fn (): mixed => $fetcher($eventId),
|
||||
['event_id' => $eventId]
|
||||
);
|
||||
}
|
||||
|
||||
public function getEventServices(int $eventId): array
|
||||
|
|
@ -137,7 +143,12 @@ public function getEventServices(int $eventId): array
|
|||
return $event?->getEventServices() ?? [];
|
||||
};
|
||||
|
||||
return $fetcher($eventId);
|
||||
return $this->logApiCall(
|
||||
'getEventServices',
|
||||
'event_services',
|
||||
fn (): array => $fetcher($eventId),
|
||||
['event_id' => $eventId]
|
||||
);
|
||||
}
|
||||
|
||||
private function fetchEvents(): array
|
||||
|
|
@ -148,7 +159,7 @@ private function fetchEvents(): array
|
|||
return EventRequest::where('from', Carbon::now()->toDateString())->get();
|
||||
};
|
||||
|
||||
return $fetcher();
|
||||
return $this->logApiCall('fetchEvents', 'events', fn (): array => $fetcher());
|
||||
}
|
||||
|
||||
private function fetchSongs(): array
|
||||
|
|
@ -159,7 +170,62 @@ private function fetchSongs(): array
|
|||
return SongRequest::all();
|
||||
};
|
||||
|
||||
return $fetcher();
|
||||
return $this->logApiCall('fetchSongs', 'songs', fn (): array => $fetcher());
|
||||
}
|
||||
|
||||
private function logApiCall(string $method, string $endpoint, Closure $operation, ?array $context = null): mixed
|
||||
{
|
||||
$start = microtime(true);
|
||||
|
||||
try {
|
||||
$result = $operation();
|
||||
$duration = (int) ((microtime(true) - $start) * 1000);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => $method,
|
||||
'endpoint' => $endpoint,
|
||||
'status' => 'success',
|
||||
'request_context' => $context,
|
||||
'response_summary' => $this->summarizeResponse($result),
|
||||
'duration_ms' => $duration,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
$duration = (int) ((microtime(true) - $start) * 1000);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => $method,
|
||||
'endpoint' => $endpoint,
|
||||
'status' => 'error',
|
||||
'request_context' => $context,
|
||||
'error_message' => $e->getMessage(),
|
||||
'duration_ms' => $duration,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function summarizeResponse(mixed $result): ?string
|
||||
{
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($result)) {
|
||||
return 'Array mit ' . count($result) . ' Eintraegen';
|
||||
}
|
||||
|
||||
if (is_scalar($result)) {
|
||||
return Str::limit((string) $result, 500);
|
||||
}
|
||||
|
||||
if (is_object($result)) {
|
||||
return 'Objekt vom Typ ' . $result::class;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function configureApi(): void
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_request_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('method');
|
||||
$table->string('endpoint');
|
||||
$table->enum('status', ['success', 'error']);
|
||||
$table->json('request_context')->nullable();
|
||||
$table->text('response_summary')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->unsignedInteger('duration_ms');
|
||||
$table->foreignId('sync_log_id')->nullable()->constrained('cts_sync_log')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_request_logs');
|
||||
}
|
||||
};
|
||||
|
|
@ -108,8 +108,15 @@ function triggerSync() {
|
|||
>
|
||||
Song-Datenbank
|
||||
</NavLink>
|
||||
<NavLink
|
||||
data-testid="auth-layout-nav-api-logs"
|
||||
:href="route('api-logs.index')"
|
||||
:active="route().current('api-logs.*')"
|
||||
>
|
||||
API-Log
|
||||
</NavLink>
|
||||
<a
|
||||
v-else
|
||||
v-if="!hasSongsRoute"
|
||||
href="#"
|
||||
class="inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium leading-5 text-gray-400 cursor-not-allowed"
|
||||
title="Song-Datenbank (demnächst)"
|
||||
|
|
@ -263,6 +270,13 @@ function triggerSync() {
|
|||
>
|
||||
Song-Datenbank
|
||||
</ResponsiveNavLink>
|
||||
<ResponsiveNavLink
|
||||
data-testid="auth-layout-mobile-nav-api-logs"
|
||||
:href="route('api-logs.index')"
|
||||
:active="route().current('api-logs.*')"
|
||||
>
|
||||
API-Log
|
||||
</ResponsiveNavLink>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Sync -->
|
||||
|
|
|
|||
172
resources/js/Pages/ApiLogs/Index.vue
Normal file
172
resources/js/Pages/ApiLogs/Index.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, Link, router } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
logs: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({ search: '', status: '' }),
|
||||
},
|
||||
})
|
||||
|
||||
const search = ref(props.filters.search ?? '')
|
||||
const status = ref(props.filters.status ?? '')
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
function loadLogs(page = 1) {
|
||||
router.get(route('api-logs.index'), {
|
||||
search: search.value || undefined,
|
||||
status: status.value || undefined,
|
||||
page,
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
watch(status, () => loadLogs(1))
|
||||
|
||||
watch(search, () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => loadLogs(1), 300)
|
||||
})
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
|
||||
return new Date(value).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function statusBadgeClass(logStatus) {
|
||||
if (logStatus === 'error') {
|
||||
return 'bg-red-100 text-red-700 ring-red-200'
|
||||
}
|
||||
|
||||
return 'bg-emerald-100 text-emerald-700 ring-emerald-200'
|
||||
}
|
||||
|
||||
function statusText(logStatus) {
|
||||
return logStatus === 'error' ? 'Fehler' : 'Erfolg'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="API-Log" />
|
||||
|
||||
<AuthenticatedLayout>
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900">API-Log</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Hier siehst du alle CTS-API-Aufrufe mit Status und Laufzeit.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-4 grid gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Suche</span>
|
||||
<input
|
||||
v-model="search"
|
||||
data-testid="api-log-search"
|
||||
type="search"
|
||||
placeholder="Methode, Endpunkt oder Fehlertext"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 shadow-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-200"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
|
||||
<select
|
||||
v-model="status"
|
||||
data-testid="api-log-status-filter"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 shadow-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-200"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="success">Erfolg</option>
|
||||
<option value="error">Fehler</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table data-testid="api-log-table" class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Zeitpunkt</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Methode</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Endpunkt</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Dauer (ms)</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Fehler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
<tr
|
||||
v-for="log in logs.data"
|
||||
:key="log.id"
|
||||
:class="log.status === 'error' ? 'bg-red-50/70' : ''"
|
||||
data-testid="api-log-row"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ formatDateTime(log.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ log.method }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ log.endpoint }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset" :class="statusBadgeClass(log.status)">
|
||||
{{ statusText(log.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ log.duration_ms }}</td>
|
||||
<td class="px-4 py-3 text-sm text-red-700">{{ log.error_message || '—' }}</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="logs.data.length === 0">
|
||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">Keine API-Logs für deinen Filter gefunden.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.links?.length > 3" class="flex flex-wrap items-center gap-1 border-t border-gray-200 px-4 py-3">
|
||||
<template v-for="link in logs.links" :key="`${link.label}-${link.url}`">
|
||||
<span
|
||||
v-if="!link.url"
|
||||
class="rounded-md border border-gray-200 px-3 py-1.5 text-xs text-gray-400"
|
||||
v-html="link.label"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
:href="link.url"
|
||||
preserve-state
|
||||
preserve-scroll
|
||||
class="rounded-md border px-3 py-1.5 text-xs transition"
|
||||
:class="link.active ? 'border-amber-500 bg-amber-50 font-semibold text-amber-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'"
|
||||
v-html="link.label"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\SongPdfController;
|
||||
use App\Http\Controllers\ArrangementController;
|
||||
use App\Http\Controllers\ServiceController;
|
||||
|
|
@ -61,6 +62,8 @@
|
|||
return Inertia::render('Songs/Index');
|
||||
})->name('songs.index');
|
||||
|
||||
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
|
||||
|
||||
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
|
||||
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
|
||||
|
|
|
|||
133
tests/Feature/ApiLogControllerTest.php
Normal file
133
tests/Feature/ApiLogControllerTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ApiRequestLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ApiLogControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_log_index_zeigt_die_api_logs_seite_mit_paginated_logs(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$user = User::factory()->create();
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'fetchEvents',
|
||||
'endpoint' => 'events',
|
||||
'status' => 'success',
|
||||
'duration_ms' => 124,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('api-logs.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->component('ApiLogs/Index')
|
||||
->has('logs.data', 1)
|
||||
->where('logs.data.0.method', 'fetchEvents')
|
||||
->where('filters.search', '')
|
||||
->where('filters.status', '')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_api_log_index_filtert_nach_suche(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$user = User::factory()->create();
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'fetchEvents',
|
||||
'endpoint' => 'events',
|
||||
'status' => 'success',
|
||||
'duration_ms' => 101,
|
||||
]);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'syncAgenda',
|
||||
'endpoint' => 'agenda',
|
||||
'status' => 'error',
|
||||
'error_message' => 'Agenda not found',
|
||||
'duration_ms' => 222,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('api-logs.index', ['search' => 'fetchEvents']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->component('ApiLogs/Index')
|
||||
->has('logs.data', 1)
|
||||
->where('logs.data.0.method', 'fetchEvents')
|
||||
->where('filters.search', 'fetchEvents')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_api_log_index_filtert_nach_status(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$user = User::factory()->create();
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'fetchSongs',
|
||||
'endpoint' => 'songs',
|
||||
'status' => 'success',
|
||||
'duration_ms' => 77,
|
||||
]);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'getEventServices',
|
||||
'endpoint' => 'event_services',
|
||||
'status' => 'error',
|
||||
'error_message' => 'Service not found',
|
||||
'duration_ms' => 309,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('api-logs.index', ['status' => 'error']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->component('ApiLogs/Index')
|
||||
->has('logs.data', 1)
|
||||
->where('logs.data.0.status', 'error')
|
||||
->where('filters.status', 'error')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_api_request_log_scopes_funktionieren(): void
|
||||
{
|
||||
ApiRequestLog::create([
|
||||
'method' => 'fetchEvents',
|
||||
'endpoint' => 'events',
|
||||
'status' => 'success',
|
||||
'duration_ms' => 51,
|
||||
]);
|
||||
|
||||
ApiRequestLog::create([
|
||||
'method' => 'syncAgenda',
|
||||
'endpoint' => 'agenda',
|
||||
'status' => 'error',
|
||||
'error_message' => 'Agenda not found',
|
||||
'duration_ms' => 120,
|
||||
]);
|
||||
|
||||
$countErrorWithSearch = ApiRequestLog::query()
|
||||
->byStatus('error')
|
||||
->search('agenda')
|
||||
->count();
|
||||
|
||||
$countWithoutFilter = ApiRequestLog::query()
|
||||
->byStatus('')
|
||||
->search('')
|
||||
->count();
|
||||
|
||||
$this->assertSame(1, $countErrorWithSearch);
|
||||
$this->assertSame(2, $countWithoutFilter);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue