From 6a2958c90a791ae79dec4d035d088d19fd07ad5f Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:16:53 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20Add=20API=20tests=20and=20update?= =?UTF-8?q?=20controllers=20for=20detailed=20course=20and=20lecturer=20dat?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🚀 Introduced feature tests for courses and lecturers, covering pagination limits, detailed data retrieval, and 404 responses. - ✏️ Updated `CourseController` to support `withDetails` for courses, including lecturer and next event data. - ✏️ Updated `LecturerController` to support `withDetails` for lecturers, including future events count. - ⚙️ Expanded routes to include `show` endpoints for courses and lecturers. --- app/Http/Controllers/Api/CourseController.php | 106 +++++++++++++-- .../Controllers/Api/LecturerController.php | 53 +++++++- routes/api.php | 2 +- .../Feature/Api/CourseLecturerReadApiTest.php | 125 ++++++++++++++++++ 4 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 tests/Feature/Api/CourseLecturerReadApiTest.php diff --git a/app/Http/Controllers/Api/CourseController.php b/app/Http/Controllers/Api/CourseController.php index b21c8eb..2edaa4b 100644 --- a/app/Http/Controllers/Api/CourseController.php +++ b/app/Http/Controllers/Api/CourseController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Api\Concerns\FiltersNumericIds; use App\Http\Controllers\Controller; use App\Models\Course; +use App\Models\CourseEvent; +use App\Models\Lecturer; use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; @@ -24,16 +26,25 @@ class CourseController extends Controller * * Öffentlicher Endpunkt; liefert id und name, alphabetisch sortiert. Ohne den Parameter * 'selected' wird das Ergebnis auf 10 Einträge begrenzt. Jeder Kurs enthält zusätzlich - * ein 'image' (Logo-Thumbnail-URL). + * ein 'image' (Logo-Thumbnail-URL). Mit 'withDetails' entfällt das Limit und jeder Kurs + * enthält zusätzlich description, lecturer (id, name, subtitle, image) und next_event + * (Beginn des nächsten zukünftigen Kurs-Events oder null). */ #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Namen des Kurses.', required: false, type: 'string')] #[QueryParameter(name: 'user_id', description: 'Filtert die Kurse nach ihrem Ersteller.', required: false, type: 'integer')] #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen Kurs-IDs.', required: false, type: 'array')] + #[QueryParameter(name: 'withDetails', description: 'Presence-Flag: liefert description, lecturer und next_event mit und hebt das 10-Einträge-Limit auf.', required: false, type: 'string')] public function index(Request $request) { + $withDetails = $request->exists('withDetails'); + return Course::query() - ->select('id', 'name') + ->select($withDetails ? ['id', 'name', 'description', 'lecturer_id'] : ['id', 'name']) + ->with('media') ->orderBy('name') + ->when($withDetails, fn (Builder $query) => $query + ->with('lecturer.media') + ->withMin(['courseEvents as next_event' => fn (Builder $events) => $events->where('from', '>=', now())], 'from')) ->when($request->has('user_id'), fn (Builder $query) => $query->where('created_by', $request->integer('user_id'))) ->when( @@ -44,17 +55,47 @@ class CourseController extends Controller ->when( $request->exists('selected'), fn (Builder $query) => $query->whereIn('id', $this->numericIds($request)), - fn (Builder $query) => $query->limit(10) + fn (Builder $query) => $withDetails ? $query : $query->limit(10) ) ->get() - ->map(function (Course $course) { + ->map(function (Course $course) use ($withDetails) { $course->image = $course->getFirstMediaUrl('logo', 'thumb'); - return $course; + if (! $withDetails) { + return $course; + } + + return [ + 'id' => $course->id, + 'name' => $course->name, + 'image' => $course->image, + 'description' => $course->description, + 'next_event' => $course->next_event, + 'lecturer' => $this->lecturerSummary($course->lecturer), + ]; }); } + /** + * Referenten-Kurzinfo für Kurs-Antworten (Liste und Detail). + * + * @return array|null + */ + private function lecturerSummary(?Lecturer $lecturer): ?array + { + if ($lecturer === null) { + return null; + } + + return [ + 'id' => $lecturer->id, + 'name' => $lecturer->name, + 'subtitle' => $lecturer->subtitle, + 'image' => $lecturer->getFirstMediaUrl('avatar', 'thumb'), + ]; + } + /** * Kurs anlegen * @@ -76,10 +117,59 @@ class CourseController extends Controller return response()->json($course->fresh(), Response::HTTP_CREATED); } - #[ExcludeRouteFromDocs] - public function show(Course $course) + /** + * Kurs anzeigen + * + * Öffentlicher Endpunkt; liefert einen Kurs mit Beschreibung, Logo, Referent + * und allen kommenden Kurs-Events (inkl. Veranstaltungsort und Stadt), + * aufsteigend nach Beginn sortiert. + * + * @return array + */ + public function show(Course $course): array { - // + $course->load([ + 'lecturer.media', + 'media', + 'courseEvents' => fn ($query) => $query + ->where('from', '>=', now()) + ->orderBy('from') + ->with('venue.city.country'), + ]); + + return [ + 'id' => $course->id, + 'name' => $course->name, + 'description' => $course->description, + 'image' => $course->getFirstMediaUrl('logo', 'preview'), + 'portalLink' => url()->route('courses.landingpage', [ + 'country' => config('app.domain_country'), + 'course' => $course, + ]), + 'lecturer' => $this->lecturerSummary($course->lecturer), + 'events' => $course->courseEvents->map(fn (CourseEvent $event) => [ + 'id' => $event->id, + 'course_id' => $event->course_id, + 'venue_id' => $event->venue_id, + 'from' => $event->from, + 'to' => $event->to, + 'link' => $event->link, + 'venue' => [ + 'id' => $event->venue->id, + 'name' => $event->venue->name, + 'city' => [ + 'id' => $event->venue->city->id, + 'name' => $event->venue->city->name, + 'country_id' => $event->venue->city->country_id, + 'country' => [ + 'id' => $event->venue->city->country->id, + 'name' => $event->venue->city->country->name, + 'code' => $event->venue->city->country->code, + ], + ], + ], + ])->all(), + ]; } /** diff --git a/app/Http/Controllers/Api/LecturerController.php b/app/Http/Controllers/Api/LecturerController.php index 8ec85bb..34ebe01 100644 --- a/app/Http/Controllers/Api/LecturerController.php +++ b/app/Http/Controllers/Api/LecturerController.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreLecturerRequest; use App\Http\Requests\Api\UpdateLecturerRequest; use App\Http\Resources\LecturerResource; +use App\Models\Course; use App\Models\Lecturer; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; @@ -26,15 +27,21 @@ class LecturerController extends Controller /** * Referenten auflisten und durchsuchen * - * Öffentlicher Endpunkt; liefert id und name, alphabetisch sortiert. Ohne den Parameter 'selected' wird die Liste auf 10 Einträge begrenzt. Jeder Referent enthält zusätzlich ein 'image' (Avatar-Thumbnail-URL). + * Öffentlicher Endpunkt; liefert id und name, alphabetisch sortiert. Ohne den Parameter 'selected' wird die Liste auf 10 Einträge begrenzt. Jeder Referent enthält zusätzlich ein 'image' (Avatar-Thumbnail-URL). Mit 'withDetails' entfällt das Limit und jeder Referent enthält zusätzlich subtitle und future_events_count (Anzahl kommender Kurs-Events). */ #[QueryParameter(name: 'search', description: 'Teilstring-Suche im Namen.', required: false, type: 'string')] #[QueryParameter(name: 'selected', description: 'Lädt gezielt die angegebenen IDs.', required: false, type: 'array')] + #[QueryParameter(name: 'withDetails', description: 'Presence-Flag: liefert subtitle und future_events_count mit und hebt das 10-Einträge-Limit auf.', required: false, type: 'string')] public function index(Request $request) { + $withDetails = $request->exists('withDetails'); + return Lecturer::query() - ->select('id', 'name') + ->select($withDetails ? ['id', 'name', 'subtitle'] : ['id', 'name']) + ->with('media') ->orderBy('name') + ->when($withDetails, fn (Builder $query) => $query + ->withCount(['coursesEvents as future_events_count' => fn (Builder $events) => $events->where('from', '>=', now())])) ->when( $request->search, fn (Builder $query) => $query @@ -43,7 +50,7 @@ class LecturerController extends Controller ->when( $request->exists('selected'), fn (Builder $query) => $query->whereIn('id', $this->numericIds($request)), - fn (Builder $query) => $query->limit(10) + fn (Builder $query) => $withDetails ? $query : $query->limit(10) ) ->get() ->map(function (Lecturer $lecturer) { @@ -54,6 +61,46 @@ class LecturerController extends Controller }); } + /** + * Referent anzeigen + * + * Öffentlicher Endpunkt; liefert das Profil eines Referenten (Avatar, Untertitel, + * Intro, Beschreibung, Nostr- und Web-Links) inklusive seiner Kurse mit deren + * nächstem zukünftigen Kurs-Event. + * + * @return array + */ + public function show(Lecturer $lecturer): array + { + $lecturer->load([ + 'media', + 'courses' => fn ($query) => $query + ->orderBy('name') + ->with('media') + ->withMin(['courseEvents as next_event' => fn (Builder $events) => $events->where('from', '>=', now())], 'from'), + ]); + + return [ + 'id' => $lecturer->id, + 'name' => $lecturer->name, + 'subtitle' => $lecturer->subtitle, + 'intro' => $lecturer->intro, + 'description' => $lecturer->description, + 'image' => $lecturer->getFirstMediaUrl('avatar', 'preview'), + 'active' => (bool) $lecturer->active, + 'nostr' => $lecturer->nostr, + 'website' => $lecturer->website, + 'twitter_username' => $lecturer->twitter_username, + 'lightning_address' => $lecturer->lightning_address, + 'courses' => $lecturer->courses->map(fn (Course $course) => [ + 'id' => $course->id, + 'name' => $course->name, + 'image' => $course->getFirstMediaUrl('logo', 'thumb'), + 'next_event' => $course->next_event, + ])->all(), + ]; + } + /** * Referent anlegen * diff --git a/routes/api.php b/routes/api.php index 0b8d6af..d9821f4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,7 +22,7 @@ Route::middleware(['throttle:60,1']) Route::resource('countries', CountryController::class)->only(['index']); Route::get('meetup/ical', [MeetupController::class, 'ical'])->name('api.meetup.ical'); Route::resource('meetup', MeetupController::class)->only(['index']); - Route::resource('lecturers', LecturerController::class)->only(['index']); + Route::resource('lecturers', LecturerController::class)->only(['index', 'show']); Route::resource('courses', CourseController::class) ->only(['index', 'show']); Route::resource('cities', CityController::class)->only(['index']); diff --git a/tests/Feature/Api/CourseLecturerReadApiTest.php b/tests/Feature/Api/CourseLecturerReadApiTest.php new file mode 100644 index 0000000..e4d7698 --- /dev/null +++ b/tests/Feature/Api/CourseLecturerReadApiTest.php @@ -0,0 +1,125 @@ +count(11)->create(); + + $this->getJson('/api/courses') + ->assertSuccessful() + ->assertJsonCount(10); +}); + +it('returns all courses with details on GET /api/courses?withDetails', function () { + $lecturer = Lecturer::factory()->create(); + $courses = Course::factory()->count(11)->create(['lecturer_id' => $lecturer->id]); + $event = CourseEvent::factory()->create([ + 'course_id' => $courses->first()->id, + 'from' => now()->addDays(3), + 'to' => now()->addDays(3)->addHours(2), + ]); + // Vergangene Events dürfen next_event nicht beeinflussen. + CourseEvent::factory()->create([ + 'course_id' => $courses->first()->id, + 'from' => now()->subDays(3), + 'to' => now()->subDays(3)->addHours(2), + ]); + + $response = $this->getJson('/api/courses?withDetails') + ->assertSuccessful() + ->assertJsonCount(11); + + $first = collect($response->json())->firstWhere('id', $courses->first()->id); + + expect($first) + ->toHaveKeys(['id', 'name', 'image', 'description', 'next_event', 'lecturer']) + ->and($first['lecturer']['id'])->toBe($lecturer->id) + ->and($first['next_event'])->toStartWith($event->from->format('Y-m-d')); +}); + +it('shows a course with upcoming events, venue and city on GET /api/courses/{course}', function () { + $course = Course::factory()->create(); + + $future = CourseEvent::factory()->create([ + 'course_id' => $course->id, + 'from' => now()->addWeek(), + 'to' => now()->addWeek()->addHours(2), + ]); + CourseEvent::factory()->create([ + 'course_id' => $course->id, + 'from' => now()->subWeek(), + 'to' => now()->subWeek()->addHours(2), + ]); + + $this->getJson('/api/courses/'.$course->id) + ->assertSuccessful() + ->assertJsonPath('id', $course->id) + ->assertJsonPath('name', $course->name) + ->assertJsonPath('lecturer.id', $course->lecturer_id) + ->assertJsonCount(1, 'events') + ->assertJsonPath('events.0.id', $future->id) + ->assertJsonPath('events.0.venue.id', $future->venue_id) + ->assertJsonPath('events.0.venue.city.name', $future->venue->city->name) + ->assertJsonPath('events.0.venue.city.country.code', $future->venue->city->country->code); +}); + +it('returns 404 for an unknown course on GET /api/courses/{course}', function () { + $this->getJson('/api/courses/999999')->assertNotFound(); +}); + +it('limits GET /api/lecturers to 10 entries without withDetails', function () { + Lecturer::factory()->count(11)->create(); + + $this->getJson('/api/lecturers') + ->assertSuccessful() + ->assertJsonCount(10); +}); + +it('returns all lecturers with details on GET /api/lecturers?withDetails', function () { + $lecturers = Lecturer::factory()->count(11)->create(); + $course = Course::factory()->create(['lecturer_id' => $lecturers->first()->id]); + CourseEvent::factory()->count(2)->create([ + 'course_id' => $course->id, + 'from' => now()->addDays(5), + 'to' => now()->addDays(5)->addHours(2), + ]); + + $response = $this->getJson('/api/lecturers?withDetails') + ->assertSuccessful() + ->assertJsonCount(11); + + $first = collect($response->json())->firstWhere('id', $lecturers->first()->id); + + expect($first) + ->toHaveKeys(['id', 'name', 'subtitle', 'image', 'future_events_count']) + ->and($first['future_events_count'])->toBe(2); +}); + +it('shows a lecturer profile with courses on GET /api/lecturers/{lecturer}', function () { + $lecturer = Lecturer::factory()->create([ + 'nostr' => 'npub1examplelecturer', + 'website' => 'https://einundzwanzig.space', + ]); + $course = Course::factory()->create(['lecturer_id' => $lecturer->id]); + $event = CourseEvent::factory()->create([ + 'course_id' => $course->id, + 'from' => now()->addDays(10), + 'to' => now()->addDays(10)->addHours(2), + ]); + + $response = $this->getJson('/api/lecturers/'.$lecturer->id) + ->assertSuccessful() + ->assertJsonPath('id', $lecturer->id) + ->assertJsonPath('nostr', 'npub1examplelecturer') + ->assertJsonPath('website', 'https://einundzwanzig.space') + ->assertJsonCount(1, 'courses') + ->assertJsonPath('courses.0.id', $course->id); + + expect($response->json('courses.0.next_event'))->toStartWith($event->from->format('Y-m-d')); +}); + +it('returns 404 for an unknown lecturer on GET /api/lecturers/{lecturer}', function () { + $this->getJson('/api/lecturers/999999')->assertNotFound(); +});