diff --git a/PROJECT.org b/PROJECT.org index 6eab67d..98eb370 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/14] :vrobbler:project:personal: +* Backlog [1/16] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: :PROPERTIES: :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb @@ -534,7 +534,27 @@ async with the POST data stored in the log["raw_data"] and used by the celery en to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed" log a warning and move on. +** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature: +:PROPERTIES: +:ID: 9ed2ec65-bf69-4300-965c-6a7d3ef7ea03 +:END: + +*** Description + +We now have the ability to share or unshare scrobbles and create private links. We should add a toggle +in the user's settings that will bulk make all their scrobbles public or private, so that a user +can either share everything, or lock their account down. + +This should not affect scrobbles that are in the "Shared" visibility state. + +Additionally, users's should have links in their settings to see what scrobbles +are either public, shared or private. Probably this could be done with a +?visbility=<> filter on the /scrobbles/ page. +** DONE [#A] Fix broken tests with new sharing and add tests :scrobbles:sharing:tests: +:PROPERTIES: +:ID: 10ecd169-eaee-8554-d4ee-f1d34bfad99f +:END: * Version 48.3 [1/1] ** DONE [#A] Fix bug in missing sqids dep :dependencies:project: :PROPERTIES: diff --git a/tests/scrobbles_tests/test_views.py b/tests/scrobbles_tests/test_views.py index c8f1e6e..be26a75 100644 --- a/tests/scrobbles_tests/test_views.py +++ b/tests/scrobbles_tests/test_views.py @@ -8,7 +8,8 @@ from django.urls import reverse from django.utils import timezone from music.models import Album, Artist, Track from podcasts.models import PodcastEpisode -from scrobbles.models import Scrobble +from scrobbles.models import Scrobble, ShareViewLog +from scrobbles.sqids import encode_scrobble_share from tasks.models import Task @@ -512,6 +513,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client): task=task, media_type="Task", user=user, + visibility="public", log={ "notes": ["First note", "Second note"], "description": "Test description", @@ -534,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client): task=task, media_type="Task", user=user, + visibility="public", log={ "notes": [ {"2024-01-01 10:00:00": "Note at first timestamp"}, @@ -562,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client): task=task, media_type="Task", user=user, + visibility="public", log={ "notes": [ {"2024-01-01 10:00:00": "Note with label"}, @@ -739,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token): ) assert response.status_code == 200 assert "scrobble_id" in response.data + + +@pytest.mark.django_db +def test_share_view_shared_visibility(client): + user = get_user_model().objects.create_user( + username="shareuser", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="shared", + timestamp=timezone.now(), + ) + url = reverse( + "scrobbles:shared-detail", + kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)}, + ) + response = client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_share_view_public_visibility(client): + user = get_user_model().objects.create_user( + username="shareuser2", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="public", + timestamp=timezone.now(), + ) + url = reverse( + "scrobbles:shared-detail", + kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)}, + ) + response = client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_share_view_private_visibility_returns_404(client): + user = get_user_model().objects.create_user( + username="shareuser3", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="private", + timestamp=timezone.now(), + ) + url = reverse( + "scrobbles:shared-detail", + kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)}, + ) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_share_view_invalid_sqid_returns_404(client): + url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"}) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_share_view_expired_token_returns_404(client): + user = get_user_model().objects.create_user( + username="shareuser4", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="shared", + timestamp=timezone.now(), + ) + old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version) + scrobble.regenerate_share_token() + url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid}) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_share_view_increments_count_and_logs_view(client): + user = get_user_model().objects.create_user( + username="shareuser5", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="shared", + timestamp=timezone.now(), + ) + assert scrobble.share_view_count == 0 + + url = reverse( + "scrobbles:shared-detail", + kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)}, + ) + response = client.get(url) + assert response.status_code == 200 + + scrobble.refresh_from_db() + assert scrobble.share_view_count == 1 + assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1 + + log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first() + assert log_entry.ip_address == "127.0.0.1" + assert log_entry.user_agent == "" + assert log_entry.referrer == "" + + +@pytest.mark.django_db +def test_explore_view_shows_only_public_scrobbles(client): + user = get_user_model().objects.create_user( + username="exploreuser", password="testpass" + ) + public_task = Task.objects.create(title="Public Task Title") + shared_task = Task.objects.create(title="Shared Task Title") + private_task = Task.objects.create(title="Private Task Title") + ts = timezone.now() + public_scrobble = Scrobble.objects.create( + task=public_task, media_type="Task", user=user, visibility="public", + timestamp=ts, + ) + Scrobble.objects.create( + task=shared_task, media_type="Task", user=user, visibility="shared", + timestamp=ts, + ) + Scrobble.objects.create( + task=private_task, media_type="Task", user=user, visibility="private", + timestamp=ts, + ) + + url = reverse("scrobbles:explore") + response = client.get(url) + assert response.status_code == 200 + + content = response.content.decode() + assert "Public Task Title" in content + assert "Shared Task Title" not in content + assert "Private Task Title" not in content + + +@pytest.mark.django_db +def test_change_visibility_owner_can_change(client): + user = get_user_model().objects.create_user( + username="visuser", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="private", + timestamp=timezone.now(), + ) + + client.force_login(user) + url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid}) + response = client.post(url, {"visibility": "shared"}) + assert response.status_code == 302 + + scrobble.refresh_from_db() + assert scrobble.visibility == "shared" + + +@pytest.mark.django_db +def test_change_visibility_non_owner_gets_404(client): + owner = get_user_model().objects.create_user( + username="owner", password="testpass" + ) + other = get_user_model().objects.create_user( + username="other", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=owner, visibility="private", + timestamp=timezone.now(), + ) + + client.force_login(other) + url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid}) + response = client.post(url, {"visibility": "shared"}) + assert response.status_code == 404 + + scrobble.refresh_from_db() + assert scrobble.visibility == "private" + + +@pytest.mark.django_db +def test_change_visibility_anonymous_redirects_to_login(client): + user = get_user_model().objects.create_user( + username="anontest", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="private", + timestamp=timezone.now(), + ) + url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid}) + response = client.post(url, {"visibility": "shared"}) + assert response.status_code == 302 + assert "/login/" in response.url + + +@pytest.mark.django_db +def test_regenerate_share_token_invalidates_old_sqid(client): + user = get_user_model().objects.create_user( + username="regentest", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="shared", + timestamp=timezone.now(), + ) + old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version) + + client.force_login(user) + url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid}) + response = client.post(url) + assert response.status_code == 302 + + scrobble.refresh_from_db() + assert scrobble.share_token_version == 1 + + old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid}) + old_response = client.get(old_url) + assert old_response.status_code == 404 + + new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version) + new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid}) + new_response = client.get(new_url) + assert new_response.status_code == 200 + + +@pytest.mark.django_db +def test_share_analytics_owner_can_view(client): + user = get_user_model().objects.create_user( + username="analyticsuser", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="shared", + timestamp=timezone.now(), + ) + + client.force_login(user) + url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid}) + response = client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_share_analytics_non_owner_gets_404(client): + owner = get_user_model().objects.create_user( + username="analyticsowner", password="testpass" + ) + other = get_user_model().objects.create_user( + username="analyticsother", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=owner, visibility="shared", + timestamp=timezone.now(), + ) + + client.force_login(other) + url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid}) + response = client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_share_analytics_shows_view_logs(client): + user = get_user_model().objects.create_user( + username="analyticsviews", password="testpass" + ) + task = Task.objects.create(title="Test Task") + scrobble = Scrobble.objects.create( + task=task, media_type="Task", user=user, visibility="shared", + timestamp=timezone.now(), + ) + + sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version) + share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid}) + client.get(share_url) + client.get(share_url) + + client.force_login(user) + url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid}) + response = client.get(url) + assert response.status_code == 200 + content = response.content.decode() + assert "127.0.0.1" in content + assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2