diff --git a/docs/features/availability-management.md b/docs/features/availability-management.md index 95d44d0..85392a6 100644 --- a/docs/features/availability-management.md +++ b/docs/features/availability-management.md @@ -41,6 +41,7 @@ Instructors access **My Availability** in wp-admin (`?page=us-availability`). - Add availability: provide a same-day start/end window, lesson length, and (optionally) a linked private-lesson offering - Add a weekly series: tick weekly repeat and choose the number of weeks - Delete a slot: only allowed if `is_booked = 0` +- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level - Current slots can be shown as a **list** or a **weekly calendar** (`usc_view=week`, navigated with `usc_week=Y-m-d`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar` ## Public Calendar @@ -77,6 +78,7 @@ lists. - REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint` ## Tests +- `tests/Unit/Availability/AvailabilityControllerTest.php` - `tests/Unit/Availability/AvailabilityRepositoryTest.php` - `tests/Unit/Availability/AvailabilitySlotTest.php` - `tests/Unit/Availability/AvailabilityEndpointTest.php` diff --git a/src/Availability/AvailabilityController.php b/src/Availability/AvailabilityController.php index 840bfed..089f7cb 100644 --- a/src/Availability/AvailabilityController.php +++ b/src/Availability/AvailabilityController.php @@ -54,17 +54,36 @@ class AvailabilityController { } if ( 'delete' === $action ) { - $slotId = absint( Val::int( $_POST['slot_id'] ?? 0 ) ); - if ( $slotId > 0 ) { - $slot = $this->repository->findById( $slotId ); - if ( $slot && $slot->instructorId === $instructorId ) { - $this->repository->delete( $slotId ); - } + $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId ); + } + + if ( 'bulk_delete' === $action ) { + // The array itself carries no data; each element is coerced and + // absint-sanitized individually below. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $rawIds = $_POST['slot_ids'] ?? []; + foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) { + $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId ); } } // phpcs:enable WordPress.Security.NonceVerification.Missing } + /** + * Delete a slot only when it exists and belongs to the given instructor. + * The repository additionally refuses to delete booked slots. + */ + private function deleteOwnSlot( int $slotId, int $instructorId ): void { + if ( $slotId <= 0 ) { + return; + } + + $slot = $this->repository->findById( $slotId ); + if ( $slot && $slot->instructorId === $instructorId ) { + $this->repository->delete( $slotId ); + } + } + private function addSlot( int $instructorId ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing $startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) ); diff --git a/templates/admin/availability.php b/templates/admin/availability.php index ab28013..11f6350 100644 --- a/templates/admin/availability.php +++ b/templates/admin/availability.php @@ -136,9 +136,22 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability

+ +
+ + +
+ @@ -149,6 +162,11 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability + @@ -162,5 +180,10 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
+ + +
+ isBooked) : ?> + + + startDt)); ?> endDt)); ?> durationMinutes . ' min'); ?>
+

+ +

diff --git a/tests/Unit/Availability/AvailabilityControllerTest.php b/tests/Unit/Availability/AvailabilityControllerTest.php new file mode 100644 index 0000000..8c7b94d --- /dev/null +++ b/tests/Unit/Availability/AvailabilityControllerTest.php @@ -0,0 +1,125 @@ +repository = Mockery::mock(AvailabilityRepository::class); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->controller = new AvailabilityController($this->repository, $this->offerings); + + $_POST = []; + $_GET = []; + + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('wp_unslash')->returnArg(); + Functions\when('sanitize_text_field')->returnArg(); + Functions\when('sanitize_key')->alias( + static fn ($key) => strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $key)) + ); + Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); + Functions\when('get_option')->justReturn(1); + Functions\when('current_time')->justReturn('2026-07-06'); + Functions\when('admin_url')->justReturn('admin.php?page=us-availability'); + Functions\when('add_query_arg')->justReturn('admin.php?page=us-availability&usc_view=week'); + Functions\when('wp_nonce_field')->justReturn(''); + Functions\when('submit_button')->justReturn(''); + Functions\when('mysql2date')->alias( + static fn (string $format, string $date) => date($format, (int) strtotime($date)) + ); + + $this->offerings->shouldReceive('findAll')->andReturn([]); + } + + public function testBulkDeleteRemovesOnlyOwnedExistingSlots(): void + { + $_POST = [ + 'usc_action' => 'bulk_delete', + 'slot_ids' => ['5', 'junk', '7', '9'], + ]; + + $owned = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 5); + $other = new AvailabilitySlot(instructorId: 4, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 7); + + $this->repository->shouldReceive('findById')->once()->with(5)->andReturn($owned); + $this->repository->shouldReceive('findById')->once()->with(7)->andReturn($other); + $this->repository->shouldReceive('findById')->once()->with(9)->andReturn(null); + $this->repository->shouldReceive('delete')->once()->with(5)->andReturn(true); + $this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $this->render(); + } + + public function testBulkDeleteIgnoresNonArrayInput(): void + { + $_POST = [ + 'usc_action' => 'bulk_delete', + 'slot_ids' => '5', + ]; + + $this->repository->shouldNotReceive('findById'); + $this->repository->shouldNotReceive('delete'); + $this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $this->render(); + } + + public function testSingleDeleteChecksOwnership(): void + { + $_POST = [ + 'usc_action' => 'delete', + 'slot_id' => '7', + ]; + + $other = new AvailabilitySlot(instructorId: 4, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 7); + + $this->repository->shouldReceive('findById')->once()->with(7)->andReturn($other); + $this->repository->shouldNotReceive('delete'); + $this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $this->render(); + } + + public function testListViewRendersBulkCheckboxesOnlyForUnbookedSlots(): void + { + $unbooked = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 5); + $booked = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 10:00:00', endDt: '2026-07-08 11:00:00', isBooked: true, id: 6); + + $this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$unbooked, $booked]); + + $html = $this->render(); + + self::assertStringContainsString('id="usc-bulk-delete-form"', $html); + self::assertStringContainsString('value="bulk_delete"', $html); + self::assertStringContainsString('Delete selected', $html); + self::assertStringContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="5"', $html); + self::assertStringNotContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="6"', $html); + } + + private function render(): string + { + ob_start(); + $this->controller->renderPage(); + + return (string) ob_get_clean(); + } +}