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;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ApiRequestLog;
|
||||||
use CTApi\CTConfig;
|
use CTApi\CTConfig;
|
||||||
use CTApi\Models\Events\Event\EventAgendaRequest;
|
use CTApi\Models\Events\Event\EventAgendaRequest;
|
||||||
use CTApi\Models\Events\Event\EventRequest;
|
use CTApi\Models\Events\Event\EventRequest;
|
||||||
|
|
@ -124,7 +125,12 @@ public function syncAgenda(int $eventId): mixed
|
||||||
return EventAgendaRequest::fromEvent($id)->get();
|
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
|
public function getEventServices(int $eventId): array
|
||||||
|
|
@ -137,7 +143,12 @@ public function getEventServices(int $eventId): array
|
||||||
return $event?->getEventServices() ?? [];
|
return $event?->getEventServices() ?? [];
|
||||||
};
|
};
|
||||||
|
|
||||||
return $fetcher($eventId);
|
return $this->logApiCall(
|
||||||
|
'getEventServices',
|
||||||
|
'event_services',
|
||||||
|
fn (): array => $fetcher($eventId),
|
||||||
|
['event_id' => $eventId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fetchEvents(): array
|
private function fetchEvents(): array
|
||||||
|
|
@ -148,7 +159,7 @@ private function fetchEvents(): array
|
||||||
return EventRequest::where('from', Carbon::now()->toDateString())->get();
|
return EventRequest::where('from', Carbon::now()->toDateString())->get();
|
||||||
};
|
};
|
||||||
|
|
||||||
return $fetcher();
|
return $this->logApiCall('fetchEvents', 'events', fn (): array => $fetcher());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fetchSongs(): array
|
private function fetchSongs(): array
|
||||||
|
|
@ -159,7 +170,62 @@ private function fetchSongs(): array
|
||||||
return SongRequest::all();
|
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
|
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
|
Song-Datenbank
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
data-testid="auth-layout-nav-api-logs"
|
||||||
|
:href="route('api-logs.index')"
|
||||||
|
:active="route().current('api-logs.*')"
|
||||||
|
>
|
||||||
|
API-Log
|
||||||
|
</NavLink>
|
||||||
<a
|
<a
|
||||||
v-else
|
v-if="!hasSongsRoute"
|
||||||
href="#"
|
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"
|
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)"
|
title="Song-Datenbank (demnächst)"
|
||||||
|
|
@ -263,6 +270,13 @@ function triggerSync() {
|
||||||
>
|
>
|
||||||
Song-Datenbank
|
Song-Datenbank
|
||||||
</ResponsiveNavLink>
|
</ResponsiveNavLink>
|
||||||
|
<ResponsiveNavLink
|
||||||
|
data-testid="auth-layout-mobile-nav-api-logs"
|
||||||
|
:href="route('api-logs.index')"
|
||||||
|
:active="route().current('api-logs.*')"
|
||||||
|
>
|
||||||
|
API-Log
|
||||||
|
</ResponsiveNavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Sync -->
|
<!-- 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
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\ApiLogController;
|
||||||
use App\Http\Controllers\SongPdfController;
|
use App\Http\Controllers\SongPdfController;
|
||||||
use App\Http\Controllers\ArrangementController;
|
use App\Http\Controllers\ArrangementController;
|
||||||
use App\Http\Controllers\ServiceController;
|
use App\Http\Controllers\ServiceController;
|
||||||
|
|
@ -61,6 +62,8 @@
|
||||||
return Inertia::render('Songs/Index');
|
return Inertia::render('Songs/Index');
|
||||||
})->name('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('/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::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||||
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
|
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