-
- ./tests/Browser
-
./tests/Unit
diff --git a/sourcecode/hub/resources/css/content/action-buttons.scss b/sourcecode/hub/resources/css/content/action-buttons.scss
index a864ca34b1..e1c2af2553 100644
--- a/sourcecode/hub/resources/css/content/action-buttons.scss
+++ b/sourcecode/hub/resources/css/content/action-buttons.scss
@@ -9,3 +9,7 @@
content: none;
}
}
+
+.action-buttons-container {
+ display: contents;
+}
diff --git a/sourcecode/hub/resources/views/components/content/action-buttons.blade.php b/sourcecode/hub/resources/views/components/content/action-buttons.blade.php
index 86c1372cb5..20e8ec619f 100644
--- a/sourcecode/hub/resources/views/components/content/action-buttons.blade.php
+++ b/sourcecode/hub/resources/views/components/content/action-buttons.blade.php
@@ -1,5 +1,19 @@
-@props(['content'])
+@props(['content', 'oob' => false])
+id)
+ id="action-buttons-{{ $content->id }}"
+ @endif
+ @if($oob && $content->id)
+ hx-swap-oob="outerHTML:#action-buttons-{{ $content->id }}"
+ @endif
+ @if($content->editUrl && $content->actionButtonsUrl && ($pollingInterval = \App\Configuration\Features::listPollingInterval()))
+ hx-get="{{ $content->actionButtonsUrl }}"
+ hx-trigger="every {{ $pollingInterval }}"
+ hx-swap="outerHTML"
+ @endif
+>
@if ($content->useUrl)
@if($content->editUrl)
@@ -39,3 +53,4 @@
@endif
+
diff --git a/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php b/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php
index 8cf4a25629..08dc6052c7 100644
--- a/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php
+++ b/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php
@@ -1,9 +1,34 @@
@php
+ use App\Configuration\Features;
use App\Support\SessionScope;
+ use Illuminate\Support\Facades\Gate;
+
$activeLock = $content->getActiveLock();
+ $canEdit = Gate::allows('edit', [$content, $version]);
+ $pollingInterval = $canEdit ? Features::detailsPollingInterval() : null;
@endphp
-@props(['content', 'version', 'explicitVersion'])
+@props([
+ 'content',
+ 'version',
+ 'explicitVersion' => false,
+ 'id' => 'details-action-buttons-header',
+ 'poll' => true,
+ 'oob' => false,
+ 'includeOob' => false,
+])
+
@can('use', [$content, $version])
+
+@if ($includeOob)
+
+@endif
diff --git a/sourcecode/hub/resources/views/components/content/result.blade.php b/sourcecode/hub/resources/views/components/content/result.blade.php
index 28548f1e9f..697b2026f1 100644
--- a/sourcecode/hub/resources/views/components/content/result.blade.php
+++ b/sourcecode/hub/resources/views/components/content/result.blade.php
@@ -9,6 +9,18 @@
@endif
+ @php
+ $editableIds = $contents->filter(fn($item) => !empty($item->editUrl))->pluck('id')->filter()->values()->all();
+ @endphp
+
+ @if ($editableIds && ($pollingInterval = \App\Configuration\Features::listPollingInterval()))
+
+ @endif
+
{{ $contents->withQueryString()->links() }}
diff --git a/sourcecode/hub/resources/views/content/bulk-action-buttons.blade.php b/sourcecode/hub/resources/views/content/bulk-action-buttons.blade.php
new file mode 100644
index 0000000000..fe5405028c
--- /dev/null
+++ b/sourcecode/hub/resources/views/content/bulk-action-buttons.blade.php
@@ -0,0 +1,5 @@
+@props(['displayItems'])
+
+@foreach ($displayItems as $content)
+
+@endforeach
diff --git a/sourcecode/hub/resources/views/content/details.blade.php b/sourcecode/hub/resources/views/content/details.blade.php
index aea7d085fd..41b583258e 100644
--- a/sourcecode/hub/resources/views/content/details.blade.php
+++ b/sourcecode/hub/resources/views/content/details.blade.php
@@ -31,7 +31,7 @@
-
+
@can('edit', [$content])
diff --git a/sourcecode/hub/resources/views/content/edit.blade.php b/sourcecode/hub/resources/views/content/edit.blade.php
index cc2385d465..8efa8e9183 100644
--- a/sourcecode/hub/resources/views/content/edit.blade.php
+++ b/sourcecode/hub/resources/views/content/edit.blade.php
@@ -21,10 +21,9 @@
>
diff --git a/sourcecode/hub/routes/web.php b/sourcecode/hub/routes/web.php
index a80cb80829..6b7f77481c 100644
--- a/sourcecode/hub/routes/web.php
+++ b/sourcecode/hub/routes/web.php
@@ -64,6 +64,9 @@
->middleware('auth')
->name('content.mine');
+ Route::get('/content/bulk-action-buttons', 'bulkActionButtons')
+ ->name('content.bulk-action-buttons');
+
Route::post('/content/toggle', 'layoutSwitch')
->name('content.layout');
@@ -205,6 +208,18 @@
->name('content.release-lock')
->can('edit', 'content')
->whereUlid('content');
+
+ Route::get('/content/{content}/action-buttons')
+ ->uses([ContentController::class, 'actionButtons'])
+ ->name('content.action-buttons')
+ ->can('view', 'content')
+ ->whereUlid('content');
+
+ Route::get('/content/{content}/details-action-buttons')
+ ->uses([ContentController::class, 'detailsActionButtons'])
+ ->name('content.details.action-buttons')
+ ->can('view', 'content')
+ ->whereUlid('content');
});
Route::prefix('/lti/dl')->middleware([
diff --git a/sourcecode/hub/tests/Feature/ContentActionButtonsTest.php b/sourcecode/hub/tests/Feature/ContentActionButtonsTest.php
new file mode 100644
index 0000000000..5a89b8a821
--- /dev/null
+++ b/sourcecode/hub/tests/Feature/ContentActionButtonsTest.php
@@ -0,0 +1,631 @@
+waitForMeilisearchTasks();
+ }
+
+ private function waitForMeilisearchTasks(): void
+ {
+ $client = app(MeilisearchClient::class);
+ $indexUid = (new Content())->searchableAs();
+ $tasks = $client->getTasks(
+ (new TasksQuery())
+ ->setIndexUids([$indexUid])
+ ->setStatuses(['enqueued', 'processing'])
+ );
+ $results = $tasks->getResults();
+ if (!empty($results)) {
+ $uids = array_column($results, 'uid');
+ $client->waitForTasks($uids);
+ }
+ }
+
+ public function testActionButtonsEndpointReturnsPollingAttributesWhenUserCanEdit(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Lock Holder User']);
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $content->acquireLock($holder);
+ $this->assertTrue($content->isLocked());
+
+ $response = $this->actingAs($owner)->get(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $response->assertOk();
+ $response->assertSee('hx-trigger="every 5s"', escape: false);
+ $response->assertSee('hx-get="' . e(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ 'forUser' => 0,
+ 'showDrafts' => 0,
+ ])), escape: false);
+ $response->assertSee('hx-swap="outerHTML"', escape: false);
+ $response->assertSee('The lock is held by: Lock Holder User.');
+ }
+
+ public function testActionButtonsEndpointDoesNotReturnPollingAttributesWhenUserCannotEdit(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Lock Holder User']);
+ $viewer = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestPublishedVersion ?? $this->fail();
+
+ $content->acquireLock($holder);
+ $this->assertTrue($content->isLocked());
+
+ $response = $this->actingAs($viewer)->get(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $response->assertOk();
+ $response->assertDontSee('hx-trigger="every', escape: false);
+ $response->assertDontSee('hx-get="' . e(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ])), escape: false);
+ }
+
+ public function testActionButtonsEndpointShowsLockWhenLockIsCreatedForEditor(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Lock Holder User']);
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $this->assertFalse($content->isLocked());
+
+ // First request when unlocked: polling is active, no lock shown
+ $responseUnlocked = $this->actingAs($owner)->get(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $responseUnlocked->assertOk();
+ $responseUnlocked->assertSee('hx-trigger="every 5s"', escape: false);
+ $responseUnlocked->assertDontSee('The lock is held by');
+
+ // Lock is acquired by another user
+ $content->acquireLock($holder);
+ $this->assertTrue($content->isLocked());
+
+ // Next poll returns locked status dynamically
+ $responseLocked = $this->actingAs($owner)->get(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $responseLocked->assertOk();
+ $responseLocked->assertSee('hx-trigger="every 5s"', escape: false);
+ $responseLocked->assertSee('The lock is held by: Lock Holder User.');
+
+ // Lock is released
+ $content->releaseLock($holder);
+ $this->assertFalse($content->isLocked());
+
+ // Subsequent poll returns unlocked status dynamically
+ $responseReleased = $this->actingAs($owner)->get(route('content.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $responseReleased->assertOk();
+ $responseReleased->assertSee('hx-trigger="every 5s"', escape: false);
+ $responseReleased->assertDontSee('The lock is held by');
+ }
+
+ public function testDetailsActionButtonsEndpointReturnsPollingAttributesWhenUserCanEdit(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Detail Lock Holder']);
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $content->acquireLock($holder);
+ $this->assertTrue($content->isLocked());
+
+ $response = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $response->assertOk();
+ $response->assertSee('id="details-action-buttons-header"', escape: false);
+ $response->assertSee('hx-trigger="every 5s"', escape: false);
+ $response->assertSee('hx-get="' . e(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ 'explicitVersion' => 0,
+ ])), escape: false);
+ $response->assertSee('hx-swap="outerHTML"', escape: false);
+ $response->assertSee('id="details-action-buttons-sidebar"', escape: false);
+ $response->assertSee('hx-swap-oob="outerHTML:#details-action-buttons-sidebar"', escape: false);
+ $this->assertSame(1, substr_count((string) $response->getContent(), 'hx-trigger="every 5s"'));
+ }
+
+ public function testDetailsActionButtonsEndpointDoesNotReturnPollingAttributesWhenUserCannotEdit(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Detail Lock Holder']);
+ $viewer = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestPublishedVersion ?? $this->fail();
+
+ $content->acquireLock($holder);
+ $this->assertTrue($content->isLocked());
+
+ $response = $this->actingAs($viewer)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $response->assertOk();
+ $response->assertDontSee('hx-trigger="every', escape: false);
+ $response->assertDontSee('content/' . $content->id . '/details-action-buttons', escape: false);
+ }
+
+ public function testDetailsActionButtonsEndpointShowsLockWhenLockIsCreatedForEditor(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Detail Lock Holder']);
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $this->assertFalse($content->isLocked());
+
+ // First request when unlocked: polling is active, no lock shown
+ $responseUnlocked = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $responseUnlocked->assertOk();
+ $responseUnlocked->assertSee('hx-trigger="every 5s"', escape: false);
+ $responseUnlocked->assertDontSee('The lock is held by Detail Lock Holder', escape: false);
+
+ // Lock is acquired by another user
+ $content->acquireLock($holder);
+ $this->assertTrue($content->isLocked());
+
+ // Next poll returns locked status dynamically for both header and sidebar OOB
+ $responseLocked = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $responseLocked->assertOk();
+ $responseLocked->assertSee('hx-trigger="every 5s"', escape: false);
+ $responseLocked->assertSee('disabled', escape: false);
+ $responseLocked->assertSee('hx-swap-oob="outerHTML:#details-action-buttons-sidebar"', escape: false);
+
+ // Lock is released
+ $content->releaseLock($holder);
+ $this->assertFalse($content->isLocked());
+
+ // Subsequent poll returns unlocked status dynamically
+ $responseReleased = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+
+ $responseReleased->assertOk();
+ $responseReleased->assertSee('hx-trigger="every 5s"', escape: false);
+ $responseReleased->assertDontSee('disabled', escape: false);
+ }
+
+ public function testDetailsPageRendersPollingWhenUserCanEdit(): void
+ {
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+
+ $response = $this->actingAs($owner)->get(route('content.details', $content));
+ $response->assertOk();
+ $response->assertSee('hx-trigger="every 5s"', escape: false);
+ $response->assertSee('content/' . $content->id . '/details-action-buttons', escape: false);
+ $response->assertSee('id="details-action-buttons-header"', escape: false);
+ $response->assertSee('id="details-action-buttons-sidebar"', escape: false);
+ // Only one polling trigger on the details page
+ $this->assertSame(1, substr_count((string) $response->getContent(), 'hx-trigger="every 5s"'));
+ }
+
+ public function testDetailsPageDoesNotRenderPollingWhenUserCannotEdit(): void
+ {
+ $owner = User::factory()->create();
+ $viewer = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+
+ $response = $this->actingAs($viewer)->get(route('content.details', $content));
+ $response->assertOk();
+ $response->assertDontSee('hx-trigger="every', escape: false);
+ $response->assertDontSee('content/' . $content->id . '/details-action-buttons', escape: false);
+
+ // Unauthenticated guest test
+ $guestResponse = $this->get(route('content.details', $content));
+ $guestResponse->assertOk();
+ $guestResponse->assertDontSee('hx-trigger="every', escape: false);
+ $guestResponse->assertDontSee('content/' . $content->id . '/details-action-buttons', escape: false);
+ }
+
+ public function testListingRendersBulkPollingForEditableItemsOnly(): void
+ {
+ $owner = User::factory()->create();
+ $viewer = User::factory()->create();
+
+ $content1 = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+
+ $content2 = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($viewer)
+ ->create();
+
+ $content1->searchable();
+ $content2->searchable();
+ $this->waitForMeilisearchTasks();
+
+ // For $owner: content1 is editable, content2 is not editable
+ $response = $this->actingAs($owner)->get(route('content.index'));
+ $response->assertOk();
+
+ // Both items have DOM IDs for action buttons
+ $response->assertSee('id="action-buttons-' . $content1->id . '"', escape: false);
+ $response->assertSee('id="action-buttons-' . $content2->id . '"', escape: false);
+
+ // Bulk polling container exists with hx-swap="none" and hx-trigger="every 5s"
+ $response->assertSee('content/bulk-action-buttons', escape: false);
+ $response->assertSee('hx-trigger="every 5s"', escape: false);
+ $response->assertSee('hx-swap="none"', escape: false);
+
+ // Bulk polling URL only includes editable content1 ID, not uneditable content2 ID
+ $response->assertSee('ids%5B0%5D=' . $content1->id, escape: false);
+ $response->assertDontSee('ids%5B1%5D=' . $content2->id, escape: false);
+ }
+
+ public function testListingDoesNotRenderBulkPollingWhenNoItemsAreEditable(): void
+ {
+ $owner = User::factory()->create();
+ $viewer = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $content->searchable();
+ $this->waitForMeilisearchTasks();
+
+ // Viewer cannot edit $content
+ $response = $this->actingAs($viewer)->get(route('content.index'));
+ $response->assertOk();
+ $response->assertDontSee('content/bulk-action-buttons', escape: false);
+ $response->assertDontSee('hx-trigger="every', escape: false);
+
+ // Guest cannot edit $content
+ $guestResponse = $this->get(route('content.index'));
+ $guestResponse->assertOk();
+ $guestResponse->assertDontSee('content/bulk-action-buttons', escape: false);
+ $guestResponse->assertDontSee('hx-trigger="every', escape: false);
+ }
+
+ public function testBulkActionButtonsEndpointReturnsOobSwapsForEditableItems(): void
+ {
+ $owner = User::factory()->create();
+ $holder = User::factory()->create(['name' => 'Batch Lock Holder']);
+
+ $content1 = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+
+ $content2 = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+
+ // Initially both are unlocked
+ $responseUnlocked = $this->actingAs($owner)->get(route('content.bulk-action-buttons', [
+ 'ids' => [$content1->id, $content2->id],
+ ]));
+
+ $responseUnlocked->assertOk();
+ $responseUnlocked->assertSee('id="action-buttons-' . $content1->id . '"', escape: false);
+ $responseUnlocked->assertSee('hx-swap-oob="outerHTML:#action-buttons-' . $content1->id . '"', escape: false);
+ $responseUnlocked->assertSee('id="action-buttons-' . $content2->id . '"', escape: false);
+ $responseUnlocked->assertSee('hx-swap-oob="outerHTML:#action-buttons-' . $content2->id . '"', escape: false);
+ $responseUnlocked->assertDontSee('The lock is held by');
+
+ // Lock content1
+ $content1->acquireLock($holder);
+ $this->assertTrue($content1->isLocked());
+
+ // Batch poll returns locked state for content1 and unlocked for content2
+ $responseLocked = $this->actingAs($owner)->get(route('content.bulk-action-buttons', [
+ 'ids' => [$content1->id, $content2->id],
+ ]));
+
+ $responseLocked->assertOk();
+ $responseLocked->assertSee('The lock is held by: Batch Lock Holder.');
+
+ // Lock is released
+ $content1->releaseLock($holder);
+ $this->assertFalse($content1->isLocked());
+
+ // Batch poll returns unlocked state again
+ $responseReleased = $this->actingAs($owner)->get(route('content.bulk-action-buttons', [
+ 'ids' => [$content1->id, $content2->id],
+ ]));
+
+ $responseReleased->assertOk();
+ $responseReleased->assertDontSee('The lock is held by');
+ }
+
+ public function testBulkActionButtonsEndpointDoesNotReturnItemsUserCannotEdit(): void
+ {
+ $owner = User::factory()->create();
+ $viewer = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+
+ $response = $this->actingAs($viewer)->get(route('content.bulk-action-buttons', [
+ 'ids' => [$content->id],
+ ]));
+
+ $response->assertOk();
+ $this->assertEmpty(trim((string) $response->getContent()));
+ }
+
+ public function testBulkActionButtonsWithEmptyIdsReturnsEmptyContent(): void
+ {
+ $viewer = User::factory()->create();
+
+ $response = $this->actingAs($viewer)->get(route('content.bulk-action-buttons'));
+ $response->assertOk();
+ $this->assertEmpty(trim((string) $response->getContent()));
+ }
+
+ public function testListingRendersCustomConfiguredPollingIntervalForEditor(): void
+ {
+ config(['features.list-polling-interval' => '10s']);
+
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $content->searchable();
+ $this->waitForMeilisearchTasks();
+
+ $response = $this->actingAs($owner)->get(route('content.index'));
+ $response->assertOk();
+ $response->assertSee('hx-trigger="every 10s"', escape: false);
+ }
+
+ public function testListingRendersNumericConfiguredPollingIntervalForEditor(): void
+ {
+ config(['features.list-polling-interval' => 15]);
+
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $content->searchable();
+ $this->waitForMeilisearchTasks();
+
+ $response = $this->actingAs($owner)->get(route('content.index'));
+ $response->assertOk();
+ $response->assertSee('hx-trigger="every 15s"', escape: false);
+ }
+
+ public function testListingRendersNoPollingWhenDisabled(): void
+ {
+ config(['features.list-polling-interval' => null]);
+
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->shared()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $content->searchable();
+ $this->waitForMeilisearchTasks();
+
+ $response = $this->actingAs($owner)->get(route('content.index'));
+ $response->assertOk();
+ $response->assertDontSee('content/bulk-action-buttons', escape: false);
+ $response->assertDontSee('hx-trigger="every', escape: false);
+
+ // Also test with 'disabled'
+ config(['features.list-polling-interval' => 'disabled']);
+ $responseDisabled = $this->actingAs($owner)->get(route('content.index'));
+ $responseDisabled->assertOk();
+ $responseDisabled->assertDontSee('content/bulk-action-buttons', escape: false);
+ $responseDisabled->assertDontSee('hx-trigger="every', escape: false);
+
+ // Also test with 0
+ config(['features.list-polling-interval' => 0]);
+ $responseZero = $this->actingAs($owner)->get(route('content.index'));
+ $responseZero->assertOk();
+ $responseZero->assertDontSee('content/bulk-action-buttons', escape: false);
+ $responseZero->assertDontSee('hx-trigger="every', escape: false);
+
+ // Also test with false
+ config(['features.list-polling-interval' => false]);
+ $responseFalse = $this->actingAs($owner)->get(route('content.index'));
+ $responseFalse->assertOk();
+ $responseFalse->assertDontSee('content/bulk-action-buttons', escape: false);
+ $responseFalse->assertDontSee('hx-trigger="every', escape: false);
+ }
+
+ public function testDetailsRendersCustomConfiguredPollingInterval(): void
+ {
+ config(['features.details-polling-interval' => '10s']);
+
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $response = $this->actingAs($owner)->get(route('content.details', $content));
+ $response->assertOk();
+ $response->assertSee('hx-trigger="every 10s"', escape: false);
+ $response->assertSee('details-action-buttons', escape: false);
+
+ // Also check the action buttons endpoint directly
+ $endpointResponse = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+ $endpointResponse->assertOk();
+ $endpointResponse->assertSee('hx-trigger="every 10s"', escape: false);
+ }
+
+ public function testDetailsRendersNumericConfiguredPollingInterval(): void
+ {
+ config(['features.details-polling-interval' => 15]);
+
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $response = $this->actingAs($owner)->get(route('content.details', $content));
+ $response->assertOk();
+ $response->assertSee('hx-trigger="every 15s"', escape: false);
+
+ $endpointResponse = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+ $endpointResponse->assertOk();
+ $endpointResponse->assertSee('hx-trigger="every 15s"', escape: false);
+ }
+
+ public function testDetailsRendersNoPollingWhenDisabled(): void
+ {
+ config(['features.details-polling-interval' => null]);
+
+ $owner = User::factory()->create();
+
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory(['published' => true]))
+ ->withUser($owner)
+ ->create();
+ $version = $content->latestVersion ?? $this->fail();
+
+ $response = $this->actingAs($owner)->get(route('content.details', $content));
+ $response->assertOk();
+ $response->assertDontSee('hx-trigger="every', escape: false);
+
+ $endpointResponse = $this->actingAs($owner)->get(route('content.details.action-buttons', [
+ $content,
+ 'version' => $version->id,
+ ]));
+ $endpointResponse->assertOk();
+ $endpointResponse->assertDontSee('hx-trigger="every', escape: false);
+
+ // Also test with 'disabled'
+ config(['features.details-polling-interval' => 'disabled']);
+ $responseDisabled = $this->actingAs($owner)->get(route('content.details', $content));
+ $responseDisabled->assertOk();
+ $responseDisabled->assertDontSee('hx-trigger="every', escape: false);
+
+ // Also test with 0
+ config(['features.details-polling-interval' => 0]);
+ $responseZero = $this->actingAs($owner)->get(route('content.details', $content));
+ $responseZero->assertOk();
+ $responseZero->assertDontSee('hx-trigger="every', escape: false);
+
+ // Also test with false
+ config(['features.details-polling-interval' => false]);
+ $responseFalse = $this->actingAs($owner)->get(route('content.details', $content));
+ $responseFalse->assertOk();
+ $responseFalse->assertDontSee('hx-trigger="every', escape: false);
+ }
+}
diff --git a/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php b/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php
index 481fb045ad..91966b6b49 100644
--- a/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php
+++ b/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php
@@ -7,6 +7,7 @@
use App\Models\Content;
use App\Models\ContentVersion;
use App\Models\LtiTool;
+use App\Models\User;
use Cerpus\EdlibResourceKit\Oauth1\Credentials;
use Cerpus\EdlibResourceKit\Oauth1\Request as Oauth1Request;
use Cerpus\EdlibResourceKit\Oauth1\SignerInterface;
@@ -193,4 +194,42 @@ public function testRejectsInvalidCredentials(): void
$this->post($url, $oauthRequest->toArray())
->assertUnauthorized();
}
+
+ public function testUpdateReleasesContentLock(): void
+ {
+ $user = User::factory()->create();
+ $content = Content::factory()
+ ->withVersion(
+ ContentVersion::factory()
+ ->for($this->tool, 'tool')
+ ->withLaunchUrl('https://ca.test/h5p/1'),
+ )
+ ->withUser($user)
+ ->create();
+ $version = $content->latestVersion;
+ $this->assertNotNull($version);
+
+ $content->acquireLock($user);
+ $this->assertTrue($content->isLocked());
+
+ $url = route('author.content.update', [$this->tool, $content, $version]);
+ $contentItems = json_encode([
+ '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem',
+ '@graph' => [
+ [
+ '@type' => 'LtiLinkItem',
+ 'url' => 'https://ca.test/h5p/1',
+ 'title' => 'Updated Title',
+ 'mediaType' => 'application/vnd.ims.lti.v1.ltilink',
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR);
+
+ $this->signedPost($url, [
+ 'content_items' => $contentItems,
+ 'user_id' => (string) $user->id,
+ ])->assertCreated();
+
+ $this->assertFalse($content->refresh()->isLocked());
+ }
}
diff --git a/sourcecode/hub/tests/Feature/ContentTest.php b/sourcecode/hub/tests/Feature/ContentTest.php
index 46bdd9a718..813cf0d9ae 100644
--- a/sourcecode/hub/tests/Feature/ContentTest.php
+++ b/sourcecode/hub/tests/Feature/ContentTest.php
@@ -13,6 +13,7 @@
use App\Models\ContentView;
use App\Models\ContentViewsAccumulated;
use App\Models\LtiPlatform;
+use App\Models\LtiTool;
use App\Models\User;
use Carbon\Carbon;
use Cerpus\EdlibResourceKit\Oauth1\Request;
@@ -353,4 +354,49 @@ public function testRefreshesLock(): void
$this->assertSame('2025-01-01T00:00:00Z', $lock->created_at?->toIso8601ZuluString());
$this->assertSame('2025-01-01T00:00:30Z', $lock->updated_at?->toIso8601ZuluString());
}
+
+ public function testLtiUpdateReleasesLock(): void
+ {
+ $user = User::factory()->create();
+ $tool = LtiTool::factory()->create([
+ 'consumer_key' => 'test-key',
+ 'consumer_secret' => 'test-secret',
+ ]);
+ $content = Content::factory()
+ ->withVersion(ContentVersion::factory()->for($tool, 'tool'))
+ ->withUser($user)
+ ->create();
+ $version = $content->latestVersion;
+ $this->assertNotNull($version);
+
+ $content->acquireLock($user);
+ $this->assertTrue($content->isLocked());
+
+ $url = route('content.lti-update', [$tool, $content, $version]);
+ $contentItems = json_encode([
+ '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem',
+ '@graph' => [
+ [
+ '@type' => 'LtiLinkItem',
+ '@id' => 'https://example.com/h5p/1',
+ 'url' => 'https://example.com/h5p/1',
+ 'title' => 'Updated Title',
+ 'mediaType' => 'application/vnd.ims.lti.v1.ltilink',
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR);
+
+ $request = new Request('POST', $url, [
+ 'content_items' => $contentItems,
+ 'lti_message_type' => 'ContentItemSelection',
+ ]);
+ $signedRequest = $this->app->make(Signer::class)->sign($request, $tool->getOauth1Credentials());
+
+ $this->actingAs($user)
+ ->withCookie('_edlib_cookies', '1')
+ ->post($url, $signedRequest->toArray())
+ ->assertOk();
+
+ $this->assertFalse($content->refresh()->isLocked());
+ }
}
diff --git a/sourcecode/hub/tests/Unit/Config/FeaturesTest.php b/sourcecode/hub/tests/Unit/Config/FeaturesTest.php
new file mode 100644
index 0000000000..5164aa1e7f
--- /dev/null
+++ b/sourcecode/hub/tests/Unit/Config/FeaturesTest.php
@@ -0,0 +1,37 @@
+assertSame($expected, Features::formatPollingInterval($input));
+ }
+}