From 7fcb88330c7964b06fb9e756c5b2ac57a5d52fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 14:52:59 +0100 Subject: [PATCH 1/9] feat: add like/unlike system for podcasts and episodes (#154) CQRS layer: - 5 commands: LikePodcast, UnlikePodcast, LikeEpisode, UnlikeEpisode, SnapshotLike - 5 events: PodcastLiked, PodcastUnliked, EpisodeLiked, EpisodeUnliked, LikeCheckpoint - Like aggregate with idempotent execute/apply and snapshot support - Router updated with like- prefix stream Projections: - UserLike schema (users.user_likes table) with partial unique indexes - LikeProjector: projects like events to user_likes table - PopularityProjector: updates likes/likes_people/score on popularity tables - @score_like = 7 (between subscribe=10 and play=5) API: - POST /api/v1/likes - like podcast or episode - DELETE /api/v1/likes/:feed - unlike podcast - DELETE /api/v1/likes/:feed/:item - unlike episode - GET /api/v1/likes - list user's active likes - JWT scopes: user.likes.read, user.likes.write - Rate limiting: 100 read/min, 30 write/min - Likes included in sync endpoint (bidirectional) Tests: 18 aggregate tests + 11 controller tests Closes #154 --- .../lib/balados_sync_core/aggregates/like.ex | 201 ++++++++++++ .../commands/like_episode.ex | 21 ++ .../commands/like_podcast.ex | 19 ++ .../commands/snapshot_like.ex | 4 + .../commands/unlike_episode.ex | 21 ++ .../commands/unlike_podcast.ex | 19 ++ .../balados_sync_core/dispatcher/router.ex | 22 +- .../balados_sync_core/events/episode_liked.ex | 11 + .../events/episode_unliked.ex | 11 + .../events/like_checkpoint.ex | 9 + .../balados_sync_core/events/podcast_liked.ex | 10 + .../events/podcast_unliked.ex | 10 + .../aggregates/like_test.exs | 307 ++++++++++++++++++ .../balados_sync_projections/application.ex | 3 +- .../projectors/like_projector.ex | 133 ++++++++ .../projectors/popularity_projector.ex | 147 ++++++++- .../schemas/user_like.ex | 29 ++ .../20260219120000_create_user_likes.exs | 37 +++ .../controllers/like_controller.ex | 151 +++++++++ .../controllers/sync_controller.ex | 107 +++++- .../lib/balados_sync_web/queries.ex | 26 +- .../lib/balados_sync_web/router.ex | 6 + .../lib/balados_sync_web/scopes.ex | 6 + .../controllers/like_controller_test.exs | 141 ++++++++ 24 files changed, 1443 insertions(+), 8 deletions(-) create mode 100644 apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/commands/like_episode.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/commands/like_podcast.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/commands/snapshot_like.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/commands/unlike_episode.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/commands/unlike_podcast.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/events/episode_liked.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/events/episode_unliked.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/events/like_checkpoint.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/events/podcast_liked.ex create mode 100644 apps/balados_sync_core/lib/balados_sync_core/events/podcast_unliked.ex create mode 100644 apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs create mode 100644 apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex create mode 100644 apps/balados_sync_projections/lib/balados_sync_projections/schemas/user_like.ex create mode 100644 apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs create mode 100644 apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex create mode 100644 apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs diff --git a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex new file mode 100644 index 00000000..41ab119e --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex @@ -0,0 +1,201 @@ +defmodule BaladosSyncCore.Aggregates.Like do + @moduledoc """ + Like aggregate for the CQRS/Event Sourcing system. + + Handles user likes for podcasts and episodes. + + ## Bounded Context + + This aggregate is part of the like bounded context, separate from + subscriptions, play tracking, playlists, and collections. + + ## State + + - `user_id` - Unique identifier for the user + - `podcast_likes` - Map of `%{feed => %{liked_at, unliked_at}}` + - `episode_likes` - Map of `%{item => %{liked_at, unliked_at, rss_source_feed}}` + """ + + defstruct [ + :user_id, + # %{rss_source_feed => %{liked_at, unliked_at}} + podcast_likes: %{}, + # %{rss_source_item => %{liked_at, unliked_at, rss_source_feed}} + episode_likes: %{} + ] + + alias BaladosSyncCore.Commands.{ + LikePodcast, + UnlikePodcast, + LikeEpisode, + UnlikeEpisode, + SnapshotLike + } + + alias BaladosSyncCore.Events.{ + PodcastLiked, + PodcastUnliked, + EpisodeLiked, + EpisodeUnliked, + LikeCheckpoint + } + + # LikePodcast - idempotent: no-op if already liked + def execute(%__MODULE__{} = state, %LikePodcast{} = cmd) do + case get_in_map(state.podcast_likes, cmd.rss_source_feed) do + %{liked_at: liked_at, unliked_at: unliked_at} + when not is_nil(liked_at) and is_nil(unliked_at) -> + [] + + _ -> + %PodcastLiked{ + user_id: cmd.user_id, + rss_source_feed: cmd.rss_source_feed, + liked_at: cmd.liked_at || DateTime.utc_now() |> DateTime.truncate(:second), + timestamp: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: cmd.event_infos || %{} + } + end + end + + # UnlikePodcast - idempotent: no-op if not currently liked + def execute(%__MODULE__{} = state, %UnlikePodcast{} = cmd) do + case get_in_map(state.podcast_likes, cmd.rss_source_feed) do + %{liked_at: liked_at, unliked_at: unliked_at} + when not is_nil(liked_at) and is_nil(unliked_at) -> + %PodcastUnliked{ + user_id: cmd.user_id, + rss_source_feed: cmd.rss_source_feed, + unliked_at: cmd.unliked_at || DateTime.utc_now() |> DateTime.truncate(:second), + timestamp: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: cmd.event_infos || %{} + } + + _ -> + [] + end + end + + # LikeEpisode - idempotent + def execute(%__MODULE__{} = state, %LikeEpisode{} = cmd) do + case get_in_map(state.episode_likes, cmd.rss_source_item) do + %{liked_at: liked_at, unliked_at: unliked_at} + when not is_nil(liked_at) and is_nil(unliked_at) -> + [] + + _ -> + %EpisodeLiked{ + user_id: cmd.user_id, + rss_source_feed: cmd.rss_source_feed, + rss_source_item: cmd.rss_source_item, + liked_at: cmd.liked_at || DateTime.utc_now() |> DateTime.truncate(:second), + timestamp: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: cmd.event_infos || %{} + } + end + end + + # UnlikeEpisode - idempotent + def execute(%__MODULE__{} = state, %UnlikeEpisode{} = cmd) do + case get_in_map(state.episode_likes, cmd.rss_source_item) do + %{liked_at: liked_at, unliked_at: unliked_at} + when not is_nil(liked_at) and is_nil(unliked_at) -> + %EpisodeUnliked{ + user_id: cmd.user_id, + rss_source_feed: cmd.rss_source_feed, + rss_source_item: cmd.rss_source_item, + unliked_at: cmd.unliked_at || DateTime.utc_now() |> DateTime.truncate(:second), + timestamp: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: cmd.event_infos || %{} + } + + _ -> + [] + end + end + + # SnapshotLike — skip if aggregate has never been initialized + def execute(%__MODULE__{user_id: nil}, %SnapshotLike{}), do: [] + + def execute(%__MODULE__{} = state, %SnapshotLike{}) do + %LikeCheckpoint{ + user_id: state.user_id, + podcast_likes: state.podcast_likes || %{}, + episode_likes: state.episode_likes || %{}, + timestamp: DateTime.utc_now() |> DateTime.truncate(:second) + } + end + + # Apply events + + def apply(%__MODULE__{} = state, %PodcastLiked{} = event) do + likes = state.podcast_likes || %{} + + updated = %{ + liked_at: event.liked_at, + unliked_at: nil + } + + %{ + state + | user_id: event.user_id, + podcast_likes: Map.put(likes, event.rss_source_feed, updated) + } + end + + def apply(%__MODULE__{} = state, %PodcastUnliked{} = event) do + likes = state.podcast_likes || %{} + + case Map.get(likes, event.rss_source_feed) do + nil -> + state + + like -> + updated = Map.put(like, :unliked_at, event.unliked_at) + %{state | podcast_likes: Map.put(likes, event.rss_source_feed, updated)} + end + end + + def apply(%__MODULE__{} = state, %EpisodeLiked{} = event) do + likes = state.episode_likes || %{} + + updated = %{ + liked_at: event.liked_at, + unliked_at: nil, + rss_source_feed: event.rss_source_feed + } + + %{ + state + | user_id: event.user_id, + episode_likes: Map.put(likes, event.rss_source_item, updated) + } + end + + def apply(%__MODULE__{} = state, %EpisodeUnliked{} = event) do + likes = state.episode_likes || %{} + + case Map.get(likes, event.rss_source_item) do + nil -> + state + + like -> + updated = Map.put(like, :unliked_at, event.unliked_at) + %{state | episode_likes: Map.put(likes, event.rss_source_item, updated)} + end + end + + def apply(%__MODULE__{} = state, %LikeCheckpoint{} = event) do + %{ + state + | user_id: event.user_id, + podcast_likes: event.podcast_likes || %{}, + episode_likes: event.episode_likes || %{} + } + end + + def apply(%__MODULE__{} = state, _event), do: state + + defp get_in_map(nil, _key), do: nil + defp get_in_map(map, key), do: Map.get(map, key) +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/commands/like_episode.ex b/apps/balados_sync_core/lib/balados_sync_core/commands/like_episode.ex new file mode 100644 index 00000000..6783ac17 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/commands/like_episode.ex @@ -0,0 +1,21 @@ +defmodule BaladosSyncCore.Commands.LikeEpisode do + @moduledoc """ + Command to like an episode. + """ + + @type t :: %__MODULE__{ + user_id: String.t(), + rss_source_feed: String.t(), + rss_source_item: String.t(), + liked_at: DateTime.t() | nil, + event_infos: map() + } + + defstruct [ + :user_id, + :rss_source_feed, + :rss_source_item, + :liked_at, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/commands/like_podcast.ex b/apps/balados_sync_core/lib/balados_sync_core/commands/like_podcast.ex new file mode 100644 index 00000000..28e6b119 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/commands/like_podcast.ex @@ -0,0 +1,19 @@ +defmodule BaladosSyncCore.Commands.LikePodcast do + @moduledoc """ + Command to like a podcast feed. + """ + + @type t :: %__MODULE__{ + user_id: String.t(), + rss_source_feed: String.t(), + liked_at: DateTime.t() | nil, + event_infos: map() + } + + defstruct [ + :user_id, + :rss_source_feed, + :liked_at, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/commands/snapshot_like.ex b/apps/balados_sync_core/lib/balados_sync_core/commands/snapshot_like.ex new file mode 100644 index 00000000..011ee9e5 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/commands/snapshot_like.ex @@ -0,0 +1,4 @@ +defmodule BaladosSyncCore.Commands.SnapshotLike do + @moduledoc "Command to snapshot the Like aggregate state." + defstruct [:user_id] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/commands/unlike_episode.ex b/apps/balados_sync_core/lib/balados_sync_core/commands/unlike_episode.ex new file mode 100644 index 00000000..6d940b13 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/commands/unlike_episode.ex @@ -0,0 +1,21 @@ +defmodule BaladosSyncCore.Commands.UnlikeEpisode do + @moduledoc """ + Command to unlike an episode. + """ + + @type t :: %__MODULE__{ + user_id: String.t(), + rss_source_feed: String.t(), + rss_source_item: String.t(), + unliked_at: DateTime.t() | nil, + event_infos: map() + } + + defstruct [ + :user_id, + :rss_source_feed, + :rss_source_item, + :unliked_at, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/commands/unlike_podcast.ex b/apps/balados_sync_core/lib/balados_sync_core/commands/unlike_podcast.ex new file mode 100644 index 00000000..ce3ff00f --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/commands/unlike_podcast.ex @@ -0,0 +1,19 @@ +defmodule BaladosSyncCore.Commands.UnlikePodcast do + @moduledoc """ + Command to unlike a podcast feed. + """ + + @type t :: %__MODULE__{ + user_id: String.t(), + rss_source_feed: String.t(), + unliked_at: DateTime.t() | nil, + event_infos: map() + } + + defstruct [ + :user_id, + :rss_source_feed, + :unliked_at, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/dispatcher/router.ex b/apps/balados_sync_core/lib/balados_sync_core/dispatcher/router.ex index bee04de1..fd7de83c 100644 --- a/apps/balados_sync_core/lib/balados_sync_core/dispatcher/router.ex +++ b/apps/balados_sync_core/lib/balados_sync_core/dispatcher/router.ex @@ -5,6 +5,7 @@ defmodule BaladosSyncCore.Dispatcher.Router do alias BaladosSyncCore.Aggregates.PlayTracking alias BaladosSyncCore.Aggregates.Playlist alias BaladosSyncCore.Aggregates.Collection + alias BaladosSyncCore.Aggregates.Like alias BaladosSyncCore.Middleware.ValidateSubscription alias BaladosSyncCore.Commands.{ @@ -32,7 +33,12 @@ defmodule BaladosSyncCore.Dispatcher.Router do UpdateCollection, DeleteCollection, ReorderCollectionFeed, - ChangeCollectionVisibility + ChangeCollectionVisibility, + LikePodcast, + UnlikePodcast, + LikeEpisode, + UnlikeEpisode, + SnapshotLike } # Registered globally (runs for all commands) because Commanded doesn't support @@ -104,4 +110,18 @@ defmodule BaladosSyncCore.Dispatcher.Router do ], to: Collection ) + + # Like aggregate (podcast and episode likes) + identify(Like, by: :user_id, prefix: "like-") + + dispatch( + [ + LikePodcast, + UnlikePodcast, + LikeEpisode, + UnlikeEpisode, + SnapshotLike + ], + to: Like + ) end diff --git a/apps/balados_sync_core/lib/balados_sync_core/events/episode_liked.ex b/apps/balados_sync_core/lib/balados_sync_core/events/episode_liked.ex new file mode 100644 index 00000000..8e4f0187 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/events/episode_liked.ex @@ -0,0 +1,11 @@ +defmodule BaladosSyncCore.Events.EpisodeLiked do + @derive Jason.Encoder + defstruct [ + :user_id, + :rss_source_feed, + :rss_source_item, + :liked_at, + :timestamp, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/events/episode_unliked.ex b/apps/balados_sync_core/lib/balados_sync_core/events/episode_unliked.ex new file mode 100644 index 00000000..6dcf32c3 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/events/episode_unliked.ex @@ -0,0 +1,11 @@ +defmodule BaladosSyncCore.Events.EpisodeUnliked do + @derive Jason.Encoder + defstruct [ + :user_id, + :rss_source_feed, + :rss_source_item, + :unliked_at, + :timestamp, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/events/like_checkpoint.ex b/apps/balados_sync_core/lib/balados_sync_core/events/like_checkpoint.ex new file mode 100644 index 00000000..b389bc66 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/events/like_checkpoint.ex @@ -0,0 +1,9 @@ +defmodule BaladosSyncCore.Events.LikeCheckpoint do + @derive Jason.Encoder + defstruct [ + :user_id, + :podcast_likes, + :episode_likes, + :timestamp + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/events/podcast_liked.ex b/apps/balados_sync_core/lib/balados_sync_core/events/podcast_liked.ex new file mode 100644 index 00000000..1aff1c53 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/events/podcast_liked.ex @@ -0,0 +1,10 @@ +defmodule BaladosSyncCore.Events.PodcastLiked do + @derive Jason.Encoder + defstruct [ + :user_id, + :rss_source_feed, + :liked_at, + :timestamp, + :event_infos + ] +end diff --git a/apps/balados_sync_core/lib/balados_sync_core/events/podcast_unliked.ex b/apps/balados_sync_core/lib/balados_sync_core/events/podcast_unliked.ex new file mode 100644 index 00000000..4fa417b3 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/events/podcast_unliked.ex @@ -0,0 +1,10 @@ +defmodule BaladosSyncCore.Events.PodcastUnliked do + @derive Jason.Encoder + defstruct [ + :user_id, + :rss_source_feed, + :unliked_at, + :timestamp, + :event_infos + ] +end diff --git a/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs b/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs new file mode 100644 index 00000000..0bd467c1 --- /dev/null +++ b/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs @@ -0,0 +1,307 @@ +defmodule BaladosSyncCore.Aggregates.LikeTest do + use ExUnit.Case, async: true + + alias BaladosSyncCore.Aggregates.Like + + alias BaladosSyncCore.Commands.{ + LikePodcast, + UnlikePodcast, + LikeEpisode, + UnlikeEpisode, + SnapshotLike + } + + alias BaladosSyncCore.Events.{ + PodcastLiked, + PodcastUnliked, + EpisodeLiked, + EpisodeUnliked, + LikeCheckpoint + } + + describe "LikePodcast command" do + test "emits PodcastLiked on new aggregate" do + state = %Like{user_id: nil} + cmd = %LikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + + event = Like.execute(state, cmd) + + assert %PodcastLiked{} = event + assert event.user_id == "user-1" + assert event.rss_source_feed == "feed-1" + assert event.liked_at != nil + end + + test "is idempotent - returns empty list if already liked" do + state = %Like{ + user_id: "user-1", + podcast_likes: %{"feed-1" => %{liked_at: ~U[2024-01-01 00:00:00Z], unliked_at: nil}} + } + + cmd = %LikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + + assert [] = Like.execute(state, cmd) + end + + test "emits PodcastLiked if previously unliked (re-like)" do + state = %Like{ + user_id: "user-1", + podcast_likes: %{ + "feed-1" => %{ + liked_at: ~U[2024-01-01 00:00:00Z], + unliked_at: ~U[2024-01-02 00:00:00Z] + } + } + } + + cmd = %LikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + + event = Like.execute(state, cmd) + + assert %PodcastLiked{} = event + end + + test "uses provided liked_at timestamp" do + state = %Like{user_id: nil} + ts = ~U[2025-06-15 12:00:00Z] + cmd = %LikePodcast{user_id: "user-1", rss_source_feed: "feed-1", liked_at: ts} + + event = Like.execute(state, cmd) + + assert event.liked_at == ts + end + end + + describe "UnlikePodcast command" do + test "emits PodcastUnliked when currently liked" do + state = %Like{ + user_id: "user-1", + podcast_likes: %{"feed-1" => %{liked_at: ~U[2024-01-01 00:00:00Z], unliked_at: nil}} + } + + cmd = %UnlikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + + event = Like.execute(state, cmd) + + assert %PodcastUnliked{} = event + assert event.rss_source_feed == "feed-1" + end + + test "is idempotent - returns empty list if not currently liked" do + state = %Like{user_id: "user-1", podcast_likes: %{}} + cmd = %UnlikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + + assert [] = Like.execute(state, cmd) + end + + test "is idempotent - returns empty list if already unliked" do + state = %Like{ + user_id: "user-1", + podcast_likes: %{ + "feed-1" => %{ + liked_at: ~U[2024-01-01 00:00:00Z], + unliked_at: ~U[2024-01-02 00:00:00Z] + } + } + } + + cmd = %UnlikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + + assert [] = Like.execute(state, cmd) + end + end + + describe "LikeEpisode command" do + test "emits EpisodeLiked on new aggregate" do + state = %Like{user_id: nil} + + cmd = %LikeEpisode{ + user_id: "user-1", + rss_source_feed: "feed-1", + rss_source_item: "item-1" + } + + event = Like.execute(state, cmd) + + assert %EpisodeLiked{} = event + assert event.rss_source_item == "item-1" + assert event.rss_source_feed == "feed-1" + end + + test "is idempotent - returns empty list if already liked" do + state = %Like{ + user_id: "user-1", + episode_likes: %{ + "item-1" => %{ + liked_at: ~U[2024-01-01 00:00:00Z], + unliked_at: nil, + rss_source_feed: "feed-1" + } + } + } + + cmd = %LikeEpisode{ + user_id: "user-1", + rss_source_feed: "feed-1", + rss_source_item: "item-1" + } + + assert [] = Like.execute(state, cmd) + end + end + + describe "UnlikeEpisode command" do + test "emits EpisodeUnliked when currently liked" do + state = %Like{ + user_id: "user-1", + episode_likes: %{ + "item-1" => %{ + liked_at: ~U[2024-01-01 00:00:00Z], + unliked_at: nil, + rss_source_feed: "feed-1" + } + } + } + + cmd = %UnlikeEpisode{ + user_id: "user-1", + rss_source_feed: "feed-1", + rss_source_item: "item-1" + } + + event = Like.execute(state, cmd) + + assert %EpisodeUnliked{} = event + assert event.rss_source_item == "item-1" + end + + test "is idempotent - returns empty list if not currently liked" do + state = %Like{user_id: "user-1", episode_likes: %{}} + + cmd = %UnlikeEpisode{ + user_id: "user-1", + rss_source_feed: "feed-1", + rss_source_item: "item-1" + } + + assert [] = Like.execute(state, cmd) + end + end + + describe "SnapshotLike command" do + test "returns empty list for uninitialized aggregate" do + state = %Like{user_id: nil} + cmd = %SnapshotLike{user_id: "user-1"} + + assert [] = Like.execute(state, cmd) + end + + test "emits LikeCheckpoint with current state" do + state = %Like{ + user_id: "user-1", + podcast_likes: %{"feed-1" => %{liked_at: ~U[2024-01-01 00:00:00Z], unliked_at: nil}}, + episode_likes: %{ + "item-1" => %{ + liked_at: ~U[2024-01-01 00:00:00Z], + unliked_at: nil, + rss_source_feed: "feed-1" + } + } + } + + cmd = %SnapshotLike{user_id: "user-1"} + + event = Like.execute(state, cmd) + + assert %LikeCheckpoint{} = event + assert event.user_id == "user-1" + assert map_size(event.podcast_likes) == 1 + assert map_size(event.episode_likes) == 1 + end + end + + describe "apply events" do + test "PodcastLiked sets liked state" do + state = %Like{user_id: nil} + + event = %PodcastLiked{ + user_id: "user-1", + rss_source_feed: "feed-1", + liked_at: ~U[2024-01-01 00:00:00Z] + } + + new_state = Like.apply(state, event) + + assert new_state.user_id == "user-1" + assert new_state.podcast_likes["feed-1"].liked_at == ~U[2024-01-01 00:00:00Z] + assert new_state.podcast_likes["feed-1"].unliked_at == nil + end + + test "PodcastUnliked sets unliked state" do + state = %Like{ + user_id: "user-1", + podcast_likes: %{"feed-1" => %{liked_at: ~U[2024-01-01 00:00:00Z], unliked_at: nil}} + } + + event = %PodcastUnliked{ + user_id: "user-1", + rss_source_feed: "feed-1", + unliked_at: ~U[2024-01-02 00:00:00Z] + } + + new_state = Like.apply(state, event) + + assert new_state.podcast_likes["feed-1"].unliked_at == ~U[2024-01-02 00:00:00Z] + end + + test "EpisodeLiked sets liked state" do + state = %Like{user_id: nil} + + event = %EpisodeLiked{ + user_id: "user-1", + rss_source_feed: "feed-1", + rss_source_item: "item-1", + liked_at: ~U[2024-01-01 00:00:00Z] + } + + new_state = Like.apply(state, event) + + assert new_state.episode_likes["item-1"].liked_at == ~U[2024-01-01 00:00:00Z] + assert new_state.episode_likes["item-1"].rss_source_feed == "feed-1" + end + + test "LikeCheckpoint restores full state" do + state = %Like{user_id: nil} + + podcast_likes = %{ + "feed-1" => %{liked_at: ~U[2024-01-01 00:00:00Z], unliked_at: nil} + } + + episode_likes = %{ + "item-1" => %{ + liked_at: ~U[2024-01-01 00:00:00Z], + unliked_at: nil, + rss_source_feed: "feed-1" + } + } + + event = %LikeCheckpoint{ + user_id: "user-1", + podcast_likes: podcast_likes, + episode_likes: episode_likes + } + + new_state = Like.apply(state, event) + + assert new_state.user_id == "user-1" + assert new_state.podcast_likes == podcast_likes + assert new_state.episode_likes == episode_likes + end + + test "unknown events are ignored" do + state = %Like{user_id: "user-1", podcast_likes: %{}, episode_likes: %{}} + new_state = Like.apply(state, %{some: "unknown event"}) + assert new_state == state + end + end +end diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/application.ex b/apps/balados_sync_projections/lib/balados_sync_projections/application.ex index 04adf794..f010b657 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/application.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/application.ex @@ -20,7 +20,8 @@ defmodule BaladosSyncProjections.Application do BaladosSyncProjections.Projectors.PlayStatusesProjector, BaladosSyncProjections.Projectors.PublicEventsProjector, BaladosSyncProjections.Projectors.PopularityProjector, - BaladosSyncProjections.Projectors.CollectionsProjector + BaladosSyncProjections.Projectors.CollectionsProjector, + BaladosSyncProjections.Projectors.LikeProjector ] else [] diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex new file mode 100644 index 00000000..b225447d --- /dev/null +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex @@ -0,0 +1,133 @@ +defmodule BaladosSyncProjections.Projectors.LikeProjector do + use Commanded.Projections.Ecto, + application: BaladosSyncCore.Dispatcher, + repo: BaladosSyncProjections.ProjectionsRepo, + name: "LikeProjector" + + require Logger + import Ecto.Query + + alias BaladosSyncCore.Events.{ + PodcastLiked, + PodcastUnliked, + EpisodeLiked, + EpisodeUnliked, + LikeCheckpoint + } + + alias BaladosSyncProjections.Schemas.UserLike + + project(%PodcastLiked{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :like_podcast, fn repo, _changes -> + upsert_like(repo, event.user_id, event.rss_source_feed, nil, event.liked_at) + end) + end) + + project(%PodcastUnliked{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :unlike_podcast, fn repo, _changes -> + unlike(repo, event.user_id, event.rss_source_feed, nil, event.unliked_at) + end) + end) + + project(%EpisodeLiked{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :like_episode, fn repo, _changes -> + upsert_like( + repo, + event.user_id, + event.rss_source_feed, + event.rss_source_item, + event.liked_at + ) + end) + end) + + project(%EpisodeUnliked{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :unlike_episode, fn repo, _changes -> + unlike(repo, event.user_id, event.rss_source_feed, event.rss_source_item, event.unliked_at) + end) + end) + + project(%LikeCheckpoint{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :checkpoint, fn repo, _changes -> + # Delete all existing likes for this user + from(ul in UserLike, where: ul.user_id == ^event.user_id) + |> repo.delete_all() + + # Re-insert podcast likes + podcast_likes = event.podcast_likes || %{} + + for {feed, like_data} <- podcast_likes do + upsert_like(repo, event.user_id, feed, nil, like_data.liked_at) + + if like_data.unliked_at do + unlike(repo, event.user_id, feed, nil, like_data.unliked_at) + end + end + + # Re-insert episode likes + episode_likes = event.episode_likes || %{} + + for {item, like_data} <- episode_likes do + upsert_like(repo, event.user_id, like_data.rss_source_feed, item, like_data.liked_at) + + if like_data.unliked_at do + unlike(repo, event.user_id, like_data.rss_source_feed, item, like_data.unliked_at) + end + end + + {:ok, :checkpoint_applied} + end) + end) + + defp upsert_like(repo, user_id, feed, item, liked_at) do + existing = find_like(repo, user_id, feed, item) + + case existing do + nil -> + %UserLike{} + |> UserLike.changeset(%{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: liked_at, + unliked_at: nil + }) + |> repo.insert() + + like -> + like + |> UserLike.changeset(%{liked_at: liked_at, unliked_at: nil}) + |> repo.update() + end + end + + defp unlike(repo, user_id, feed, item, unliked_at) do + existing = find_like(repo, user_id, feed, item) + + case existing do + nil -> + {:ok, nil} + + like -> + like + |> UserLike.changeset(%{unliked_at: unliked_at}) + |> repo.update() + end + end + + defp find_like(repo, user_id, feed, nil) do + from(ul in UserLike, + where: ul.user_id == ^user_id and ul.rss_source_feed == ^feed and is_nil(ul.rss_source_item) + ) + |> repo.one() + end + + defp find_like(repo, user_id, feed, item) do + from(ul in UserLike, + where: + ul.user_id == ^user_id and ul.rss_source_feed == ^feed and + ul.rss_source_item == ^item + ) + |> repo.one() + end +end diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex index bae44597..bddff8aa 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex @@ -13,7 +13,11 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do PlayRecorded, EpisodeSaved, EpisodeShared, - PopularityRecalculated + PopularityRecalculated, + PodcastLiked, + PodcastUnliked, + EpisodeLiked, + EpisodeUnliked } alias BaladosSyncCore.RssCache @@ -23,6 +27,7 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do # Scores par type d'action @score_subscribe 10 + @score_like 7 @score_play 5 @score_save 3 @score_share 2 @@ -382,6 +387,146 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do end) end) + project(%PodcastLiked{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :podcast_liked, fn repo, _changes -> + is_public = is_user_public_with_repo?(repo, event.user_id, event.rss_source_feed, nil) + + popularity = + repo.get(PodcastPopularity, event.rss_source_feed) || + %PodcastPopularity{rss_source_feed: event.rss_source_feed} + + updated = %{ + popularity + | score: popularity.score + @score_like, + likes: popularity.likes + 1, + likes_people: + if(is_public, + do: add_recent_user(popularity.likes_people, event.user_id), + else: popularity.likes_people + ) + } + + repo.insert_or_update( + PodcastPopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end) + end) + + project(%PodcastUnliked{} = event, _metadata, fn multi -> + Ecto.Multi.run(multi, :podcast_unliked, fn repo, _changes -> + popularity = + repo.get(PodcastPopularity, event.rss_source_feed) || + %PodcastPopularity{rss_source_feed: event.rss_source_feed} + + updated = %{ + popularity + | score: max(popularity.score - @score_like, 0), + likes: max(popularity.likes - 1, 0), + likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) + } + + repo.insert_or_update( + PodcastPopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end) + end) + + project(%EpisodeLiked{} = event, _metadata, fn multi -> + # Increment episode popularity + multi = + Ecto.Multi.run(multi, :episode_liked, fn repo, _changes -> + is_public = + is_user_public_with_repo?( + repo, + event.user_id, + event.rss_source_feed, + event.rss_source_item + ) + + popularity = + repo.get(EpisodePopularity, event.rss_source_item) || + %EpisodePopularity{ + rss_source_item: event.rss_source_item, + rss_source_feed: event.rss_source_feed + } + + updated = %{ + popularity + | score: popularity.score + @score_like, + likes: popularity.likes + 1, + likes_people: + if(is_public, + do: add_recent_user(popularity.likes_people, event.user_id), + else: popularity.likes_people + ) + } + + repo.insert_or_update( + EpisodePopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_item + ) + end) + + # Also increment podcast score for episode likes + Ecto.Multi.run(multi, :podcast_score_from_episode_like, fn repo, _changes -> + popularity = + repo.get(PodcastPopularity, event.rss_source_feed) || + %PodcastPopularity{rss_source_feed: event.rss_source_feed} + + updated = %{popularity | score: popularity.score + @score_like} + + repo.insert_or_update( + PodcastPopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end) + end) + + project(%EpisodeUnliked{} = event, _metadata, fn multi -> + multi = + Ecto.Multi.run(multi, :episode_unliked, fn repo, _changes -> + popularity = + repo.get(EpisodePopularity, event.rss_source_item) || + %EpisodePopularity{ + rss_source_item: event.rss_source_item, + rss_source_feed: event.rss_source_feed + } + + updated = %{ + popularity + | score: max(popularity.score - @score_like, 0), + likes: max(popularity.likes - 1, 0), + likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) + } + + repo.insert_or_update( + EpisodePopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_item + ) + end) + + Ecto.Multi.run(multi, :podcast_score_from_episode_unlike, fn repo, _changes -> + popularity = + repo.get(PodcastPopularity, event.rss_source_feed) || + %PodcastPopularity{rss_source_feed: event.rss_source_feed} + + updated = %{popularity | score: max(popularity.score - @score_like, 0)} + + repo.insert_or_update( + PodcastPopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end) + end) + project(%PopularityRecalculated{} = event, _metadata, fn multi -> # Event émis par le worker après recalcul depuis public_events if event.rss_source_item do diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/schemas/user_like.ex b/apps/balados_sync_projections/lib/balados_sync_projections/schemas/user_like.ex new file mode 100644 index 00000000..74d0feb2 --- /dev/null +++ b/apps/balados_sync_projections/lib/balados_sync_projections/schemas/user_like.ex @@ -0,0 +1,29 @@ +defmodule BaladosSyncProjections.Schemas.UserLike do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @schema_prefix "users" + schema "user_likes" do + field :user_id, :string + field :rss_source_feed, :string + # nil for podcast likes, set for episode likes + field :rss_source_item, :string + field :liked_at, :utc_datetime + field :unliked_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + def changeset(user_like, attrs) do + user_like + |> cast(attrs, [ + :user_id, + :rss_source_feed, + :rss_source_item, + :liked_at, + :unliked_at + ]) + |> validate_required([:user_id, :rss_source_feed, :liked_at]) + end +end diff --git a/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs b/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs new file mode 100644 index 00000000..04f8fb6c --- /dev/null +++ b/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs @@ -0,0 +1,37 @@ +defmodule BaladosSyncProjections.ProjectionsRepo.Migrations.CreateUserLikes do + use Ecto.Migration + + def up do + create table(:user_likes, prefix: "users", primary_key: false) do + add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") + add :user_id, :string, null: false + add :rss_source_feed, :string, null: false + add :rss_source_item, :string + add :liked_at, :utc_datetime, null: false + add :unliked_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + # Unique constraint: one like per user per feed (podcast) or per user per feed+item (episode) + # Using coalesce to handle NULL rss_source_item for podcast likes + create unique_index(:user_likes, [:user_id, :rss_source_feed], + prefix: "users", + where: "rss_source_item IS NULL", + name: "user_likes_user_feed_unique" + ) + + create unique_index(:user_likes, [:user_id, :rss_source_feed, :rss_source_item], + prefix: "users", + where: "rss_source_item IS NOT NULL", + name: "user_likes_user_feed_item_unique" + ) + + create index(:user_likes, [:user_id], prefix: "users") + create index(:user_likes, [:rss_source_feed], prefix: "users") + end + + def down do + drop table(:user_likes, prefix: "users") + end +end diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex new file mode 100644 index 00000000..622594a0 --- /dev/null +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex @@ -0,0 +1,151 @@ +defmodule BaladosSyncWeb.LikeController do + @moduledoc """ + Controller for managing podcast and episode likes. + + ## Routes + + - `POST /api/v1/likes` - Like a podcast or episode + - `DELETE /api/v1/likes/:feed` - Unlike a podcast + - `DELETE /api/v1/likes/:feed/:item` - Unlike an episode + - `GET /api/v1/likes` - List all active likes for the user + """ + + use BaladosSyncWeb, :controller + + alias BaladosSyncCore.Dispatcher + + alias BaladosSyncCore.Commands.{ + LikePodcast, + UnlikePodcast, + LikeEpisode, + UnlikeEpisode + } + + alias BaladosSyncProjections.ProjectionsRepo + alias BaladosSyncProjections.Schemas.UserLike + alias BaladosSyncWeb.Plugs.JWTAuth + alias BaladosSyncWeb.Plugs.RateLimiter + import BaladosSyncWeb.ErrorHelpers + import Ecto.Query + + plug JWTAuth, [scopes: ["user.likes.read"]] when action in [:index] + + plug JWTAuth, + [scopes: ["user.likes.write"]] when action in [:create, :delete_podcast, :delete_episode] + + plug RateLimiter, + [limit: 100, window_ms: 60_000, key: :user_id, namespace: "likes_read"] + when action in [:index] + + plug RateLimiter, + [limit: 30, window_ms: 60_000, key: :user_id, namespace: "likes_write"] + when action in [:create, :delete_podcast, :delete_episode] + + def create(conn, %{"rss_source_feed" => feed, "rss_source_item" => item}) + when is_binary(item) and item != "" do + user_id = conn.assigns.current_user_id + device_id = conn.assigns.device_id + device_name = conn.assigns.device_name + + command = %LikeEpisode{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: %{device_id: device_id, device_name: device_name} + } + + case Dispatcher.dispatch(command) do + :ok -> + json(conn, %{status: "success"}) + + {:error, reason} -> + handle_dispatch_error(conn, reason) + end + end + + def create(conn, %{"rss_source_feed" => feed}) do + user_id = conn.assigns.current_user_id + device_id = conn.assigns.device_id + device_name = conn.assigns.device_name + + command = %LikePodcast{ + user_id: user_id, + rss_source_feed: feed, + liked_at: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: %{device_id: device_id, device_name: device_name} + } + + case Dispatcher.dispatch(command) do + :ok -> + json(conn, %{status: "success"}) + + {:error, reason} -> + handle_dispatch_error(conn, reason) + end + end + + def create(conn, _params) do + bad_request(conn, "Missing required parameter: rss_source_feed") + end + + def delete_podcast(conn, %{"feed" => feed}) do + user_id = conn.assigns.current_user_id + device_id = conn.assigns.device_id + device_name = conn.assigns.device_name + + command = %UnlikePodcast{ + user_id: user_id, + rss_source_feed: feed, + unliked_at: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: %{device_id: device_id, device_name: device_name} + } + + case Dispatcher.dispatch(command) do + :ok -> + json(conn, %{status: "success"}) + + {:error, reason} -> + handle_dispatch_error(conn, reason) + end + end + + def delete_episode(conn, %{"feed" => feed, "item" => item}) do + user_id = conn.assigns.current_user_id + device_id = conn.assigns.device_id + device_name = conn.assigns.device_name + + command = %UnlikeEpisode{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + unliked_at: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: %{device_id: device_id, device_name: device_name} + } + + case Dispatcher.dispatch(command) do + :ok -> + json(conn, %{status: "success"}) + + {:error, reason} -> + handle_dispatch_error(conn, reason) + end + end + + def index(conn, _params) do + user_id = conn.assigns.current_user_id + + likes = + from(ul in UserLike, + where: ul.user_id == ^user_id and is_nil(ul.unliked_at), + select: %{ + rss_source_feed: ul.rss_source_feed, + rss_source_item: ul.rss_source_item, + liked_at: ul.liked_at + } + ) + |> ProjectionsRepo.all() + + json(conn, %{likes: likes}) + end +end diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index f4a74ad6..564edc06 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -15,7 +15,15 @@ defmodule BaladosSyncWeb.SyncController do import Ecto.Query alias Ecto.Multi alias BaladosSyncProjections.ProjectionsRepo - alias BaladosSyncProjections.Schemas.{Subscription, PlayStatus, Playlist, PlaylistItem} + + alias BaladosSyncProjections.Schemas.{ + Subscription, + PlayStatus, + Playlist, + PlaylistItem, + UserLike + } + alias BaladosSyncCore.SyncResolver alias BaladosSyncWeb.Plugs.JWTAuth alias BaladosSyncWeb.Plugs.RateLimiter @@ -67,6 +75,7 @@ defmodule BaladosSyncWeb.SyncController do parse_play_statuses(client_changes["plays"] || client_changes["play_statuses"] || []) playlists = parse_playlists(client_changes["playlists"] || []) + likes = parse_likes(client_changes["likes"] || []) # Get server state for conflict detection server_data = %{ @@ -78,7 +87,8 @@ defmodule BaladosSyncWeb.SyncController do client_data = %{ subscriptions: subscriptions, play_statuses: play_statuses, - playlists: playlists + playlists: playlists, + likes: likes } # Build multi transaction with conflict resolution @@ -141,6 +151,9 @@ defmodule BaladosSyncWeb.SyncController do now ) + # Sync likes (LWW) + multi = sync_likes(multi, user_id, client_data.likes, now) + all_conflicts = (sub_conflicts ++ play_conflicts ++ playlist_conflicts) |> Enum.filter(& &1) @@ -374,6 +387,63 @@ defmodule BaladosSyncWeb.SyncController do end) end + # Sync likes with LWW + defp sync_likes(multi, _user_id, likes, _now) when likes == [], do: multi + + defp sync_likes(multi, user_id, likes, now) do + Enum.reduce(likes, multi, fn like, acc_multi -> + feed = like.rss_source_feed + item = like.rss_source_item + + Multi.run(acc_multi, {:like, feed, item || "podcast"}, fn repo, _changes -> + existing = + if is_nil(item) do + from(ul in UserLike, + where: + ul.user_id == ^user_id and ul.rss_source_feed == ^feed and + is_nil(ul.rss_source_item) + ) + |> repo.one() + else + from(ul in UserLike, + where: + ul.user_id == ^user_id and ul.rss_source_feed == ^feed and + ul.rss_source_item == ^item + ) + |> repo.one() + end + + attrs = %{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: like.liked_at || now, + unliked_at: like.unliked_at + } + + case existing do + nil -> + %UserLike{} + |> UserLike.changeset(attrs) + |> repo.insert() + + record -> + # LWW: update if client is newer + client_ts = like.liked_at || now + server_ts = record.liked_at || ~U[1970-01-01 00:00:00Z] + + if DateTime.compare(client_ts, server_ts) != :lt do + record + |> UserLike.changeset(attrs) + |> repo.update() + else + {:ok, record} + end + end + end) + end) + end + defp get_server_subscriptions(user_id) do from(s in Subscription, where: s.user_id == ^user_id) |> ProjectionsRepo.all() @@ -428,7 +498,8 @@ defmodule BaladosSyncWeb.SyncController do %{ subscriptions: get_subscriptions_since(user_id, last_sync), plays: get_play_statuses_since(user_id, last_sync), - playlists: get_playlists_since(user_id, last_sync) + playlists: get_playlists_since(user_id, last_sync), + likes: get_likes_since(user_id, last_sync) } end @@ -492,6 +563,19 @@ defmodule BaladosSyncWeb.SyncController do end) end + defp get_likes_since(user_id, since) do + from(ul in UserLike, + where: ul.user_id == ^user_id and ul.updated_at > ^since, + select: %{ + rss_source_feed: ul.rss_source_feed, + rss_source_item: ul.rss_source_item, + liked_at: ul.liked_at, + unliked_at: ul.unliked_at + } + ) + |> ProjectionsRepo.all() + end + # Format conflicts for API response defp format_conflicts(conflicts) do Enum.map(conflicts, fn conflict -> @@ -590,6 +674,20 @@ defmodule BaladosSyncWeb.SyncController do defp parse_playlist_items(_), do: [] + defp parse_likes(likes) when is_list(likes) do + Enum.map(likes, fn like -> + %{ + rss_source_feed: like["rss_source_feed"], + rss_source_item: like["rss_source_item"], + liked_at: parse_datetime(like["liked_at"]), + unliked_at: parse_datetime(like["unliked_at"]) + } + end) + |> Enum.filter(fn like -> is_binary(like.rss_source_feed) end) + end + + defp parse_likes(_), do: [] + defp parse_datetime(nil), do: nil defp parse_datetime(dt) when is_binary(dt) do @@ -610,7 +708,8 @@ defmodule BaladosSyncWeb.SyncController do subscriptions: BaladosSyncWeb.Queries.get_user_subscriptions(user_id), plays: BaladosSyncWeb.Queries.get_user_play_statuses(user_id), # Include all playlist types (regular playlists and queues) for sync - playlists: BaladosSyncWeb.Queries.get_user_playlists(user_id, include_all_types: true) + playlists: BaladosSyncWeb.Queries.get_user_playlists(user_id, include_all_types: true), + likes: BaladosSyncWeb.Queries.get_user_likes(user_id) } end end diff --git a/apps/balados_sync_web/lib/balados_sync_web/queries.ex b/apps/balados_sync_web/lib/balados_sync_web/queries.ex index 08a5dcb4..9fc75038 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/queries.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/queries.ex @@ -1,7 +1,14 @@ defmodule BaladosSyncWeb.Queries do import Ecto.Query alias BaladosSyncProjections.ProjectionsRepo - alias BaladosSyncProjections.Schemas.{Subscription, PlayStatus, Playlist, PlaylistItem} + + alias BaladosSyncProjections.Schemas.{ + Subscription, + PlayStatus, + Playlist, + PlaylistItem, + UserLike + } def get_user_subscriptions(user_id) do from(s in Subscription, @@ -112,6 +119,23 @@ defmodule BaladosSyncWeb.Queries do } end + def get_user_likes(user_id) do + from(ul in UserLike, + where: ul.user_id == ^user_id and is_nil(ul.unliked_at), + order_by: [desc: ul.liked_at] + ) + |> ProjectionsRepo.all() + |> Enum.map(&format_like/1) + end + + defp format_like(like) do + %{ + rss_source_feed: like.rss_source_feed, + rss_source_item: like.rss_source_item, + liked_at: like.liked_at + } + end + @doc """ Get paginated listening history for a user with optional filters. diff --git a/apps/balados_sync_web/lib/balados_sync_web/router.ex b/apps/balados_sync_web/lib/balados_sync_web/router.ex index c2492be1..a51c2652 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/router.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/router.ex @@ -253,6 +253,12 @@ defmodule BaladosSyncWeb.Router do post "/collections/:id/feeds", CollectionsController, :add_feed delete "/collections/:id/feeds/:feed_id", CollectionsController, :remove_feed + # Likes + post "/likes", LikeController, :create + delete "/likes/:feed", LikeController, :delete_podcast + delete "/likes/:feed/:item", LikeController, :delete_episode + get "/likes", LikeController, :index + # Play status post "/play", PlayController, :record put "/play/:item/position", PlayController, :update_position diff --git a/apps/balados_sync_web/lib/balados_sync_web/scopes.ex b/apps/balados_sync_web/lib/balados_sync_web/scopes.ex index 09521449..ec8b092e 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/scopes.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/scopes.ex @@ -19,6 +19,9 @@ defmodule BaladosSyncWeb.Scopes do - `user.plays` - Play status management (includes read and write) - `user.plays.read` - List play statuses and positions - `user.plays.write` - Update play positions and mark as played + - `user.likes` - Like management (includes read and write) + - `user.likes.read` - List likes + - `user.likes.write` - Like/unlike podcasts and episodes - `user.playlists` - Playlist management (includes read and write) - `user.playlists.read` - List playlists - `user.playlists.write` - Create/update/delete playlists @@ -64,6 +67,9 @@ defmodule BaladosSyncWeb.Scopes do "user.plays" => "Full access to play status and positions", "user.plays.read" => "Read playback positions and play status", "user.plays.write" => "Update playback positions and mark episodes as played", + "user.likes" => "Full access to likes", + "user.likes.read" => "List liked podcasts and episodes", + "user.likes.write" => "Like and unlike podcasts and episodes", "user.playlists" => "Full access to playlists", "user.playlists.read" => "List playlists and their contents", "user.playlists.write" => "Create, update, and delete playlists", diff --git a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs new file mode 100644 index 00000000..eec0d393 --- /dev/null +++ b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs @@ -0,0 +1,141 @@ +defmodule BaladosSyncWeb.LikeControllerTest do + use BaladosSyncWeb.ConnCase + + alias BaladosSyncCore.Dispatcher + alias BaladosSyncCore.Commands.LikePodcast + alias BaladosSyncProjections.ProjectionsRepo + alias BaladosSyncProjections.Schemas.UserLike + alias BaladosSyncWeb.JwtTestHelper + + setup do + user_id = Ecto.UUID.generate() + {:ok, user_id: user_id} + end + + describe "POST /api/v1/likes - authentication" do + test "returns 401 without authorization header", %{conn: conn} do + conn = post(conn, "/api/v1/likes", %{}) + assert json_response(conn, 401)["error"] == "UNAUTHORIZED" + end + + test "returns 403 with insufficient scopes", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.read"]) + |> post("/api/v1/likes", %{"rss_source_feed" => "dGVzdA"}) + + assert json_response(conn, 403)["error"] == "FORBIDDEN" + end + end + + describe "POST /api/v1/likes - like podcast" do + test "returns 400 when rss_source_feed is missing", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.write"]) + |> post("/api/v1/likes", %{}) + + response = json_response(conn, 400) + assert response["error"] == "BAD_REQUEST" + end + + test "successfully likes a podcast", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.write"]) + |> post("/api/v1/likes", %{"rss_source_feed" => "dGVzdC1mZWVk"}) + + response = json_response(conn, 200) + assert response["status"] == "success" + end + + test "successfully likes an episode", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.write"]) + |> post("/api/v1/likes", %{ + "rss_source_feed" => "dGVzdC1mZWVk", + "rss_source_item" => "dGVzdC1pdGVt" + }) + + response = json_response(conn, 200) + assert response["status"] == "success" + end + end + + describe "DELETE /api/v1/likes/:feed - unlike podcast" do + test "returns 401 without authorization", %{conn: conn} do + conn = delete(conn, "/api/v1/likes/dGVzdC1mZWVk") + assert json_response(conn, 401)["error"] == "UNAUTHORIZED" + end + + test "successfully unlikes a podcast", %{conn: conn, user_id: user_id} do + # First like the podcast + Dispatcher.dispatch(%LikePodcast{ + user_id: user_id, + rss_source_feed: "dGVzdC1mZWVk", + liked_at: DateTime.utc_now() |> DateTime.truncate(:second), + event_infos: %{} + }) + + Process.sleep(50) + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.write"]) + |> delete("/api/v1/likes/dGVzdC1mZWVk") + + response = json_response(conn, 200) + assert response["status"] == "success" + end + end + + describe "GET /api/v1/likes - list likes" do + test "returns 401 without authorization", %{conn: conn} do + conn = get(conn, "/api/v1/likes") + assert json_response(conn, 401)["error"] == "UNAUTHORIZED" + end + + test "returns empty list for user with no likes", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.read"]) + |> get("/api/v1/likes") + + response = json_response(conn, 200) + assert response["likes"] == [] + end + + test "returns likes after liking a podcast", %{conn: conn, user_id: user_id} do + # Insert directly into projections (projectors don't run in test env) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + ProjectionsRepo.insert!(%UserLike{ + user_id: user_id, + rss_source_feed: "dGVzdC1mZWVk", + rss_source_item: nil, + liked_at: now, + unliked_at: nil + }) + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.read"]) + |> get("/api/v1/likes") + + response = json_response(conn, 200) + assert length(response["likes"]) == 1 + assert hd(response["likes"])["rss_source_feed"] == "dGVzdC1mZWVk" + end + + test "works with wildcard scope", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["*"]) + |> get("/api/v1/likes") + + response = json_response(conn, 200) + assert response["likes"] == [] + end + end +end From 7778e83e2ae9aed1332e2235753b9ce6481e90a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 15:04:34 +0100 Subject: [PATCH 2/9] fix: address all review remarks from PR #255 MUST-FIX: - Fix LikeCheckpoint projector error swallowing: use Enum.reduce_while with explicit error propagation instead of for comprehensions - Add Logger calls to all LikeProjector event handlers SHOULD-FIX: - Add LikeProjector tests (10 tests covering like/unlike/checkpoint) - Add comment explaining sync_likes CQRS bypass trade-off - Add comment explaining get_user_likes active-only filtering NICE-TO-HAVE: - Remove redundant get_in_map/2 helper (use Map.get directly) - Remove redundant || %{} guards in aggregate apply/2 functions - Remove Process.sleep(50) from controller test - Add comment about get_user_likes filtering rationale Co-Authored-By: Claude Opus 4.6 --- .../lib/balados_sync_core/aggregates/like.ex | 35 +-- .../projectors/like_projector.ex | 78 +++-- .../projectors/like_projector_test.exs | 277 ++++++++++++++++++ .../test/support/projector_test_case.ex | 172 ++++++++++- .../controllers/sync_controller.ex | 8 + .../lib/balados_sync_web/queries.ex | 3 + .../controllers/like_controller_test.exs | 4 +- 7 files changed, 532 insertions(+), 45 deletions(-) create mode 100644 apps/balados_sync_projections/test/balados_sync_projections/projectors/like_projector_test.exs diff --git a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex index 41ab119e..665a6cdc 100644 --- a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex +++ b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex @@ -42,7 +42,7 @@ defmodule BaladosSyncCore.Aggregates.Like do # LikePodcast - idempotent: no-op if already liked def execute(%__MODULE__{} = state, %LikePodcast{} = cmd) do - case get_in_map(state.podcast_likes, cmd.rss_source_feed) do + case Map.get(state.podcast_likes, cmd.rss_source_feed) do %{liked_at: liked_at, unliked_at: unliked_at} when not is_nil(liked_at) and is_nil(unliked_at) -> [] @@ -60,7 +60,7 @@ defmodule BaladosSyncCore.Aggregates.Like do # UnlikePodcast - idempotent: no-op if not currently liked def execute(%__MODULE__{} = state, %UnlikePodcast{} = cmd) do - case get_in_map(state.podcast_likes, cmd.rss_source_feed) do + case Map.get(state.podcast_likes, cmd.rss_source_feed) do %{liked_at: liked_at, unliked_at: unliked_at} when not is_nil(liked_at) and is_nil(unliked_at) -> %PodcastUnliked{ @@ -78,7 +78,7 @@ defmodule BaladosSyncCore.Aggregates.Like do # LikeEpisode - idempotent def execute(%__MODULE__{} = state, %LikeEpisode{} = cmd) do - case get_in_map(state.episode_likes, cmd.rss_source_item) do + case Map.get(state.episode_likes, cmd.rss_source_item) do %{liked_at: liked_at, unliked_at: unliked_at} when not is_nil(liked_at) and is_nil(unliked_at) -> [] @@ -97,7 +97,7 @@ defmodule BaladosSyncCore.Aggregates.Like do # UnlikeEpisode - idempotent def execute(%__MODULE__{} = state, %UnlikeEpisode{} = cmd) do - case get_in_map(state.episode_likes, cmd.rss_source_item) do + case Map.get(state.episode_likes, cmd.rss_source_item) do %{liked_at: liked_at, unliked_at: unliked_at} when not is_nil(liked_at) and is_nil(unliked_at) -> %EpisodeUnliked{ @@ -120,8 +120,8 @@ defmodule BaladosSyncCore.Aggregates.Like do def execute(%__MODULE__{} = state, %SnapshotLike{}) do %LikeCheckpoint{ user_id: state.user_id, - podcast_likes: state.podcast_likes || %{}, - episode_likes: state.episode_likes || %{}, + podcast_likes: state.podcast_likes, + episode_likes: state.episode_likes, timestamp: DateTime.utc_now() |> DateTime.truncate(:second) } end @@ -129,8 +129,6 @@ defmodule BaladosSyncCore.Aggregates.Like do # Apply events def apply(%__MODULE__{} = state, %PodcastLiked{} = event) do - likes = state.podcast_likes || %{} - updated = %{ liked_at: event.liked_at, unliked_at: nil @@ -139,26 +137,22 @@ defmodule BaladosSyncCore.Aggregates.Like do %{ state | user_id: event.user_id, - podcast_likes: Map.put(likes, event.rss_source_feed, updated) + podcast_likes: Map.put(state.podcast_likes, event.rss_source_feed, updated) } end def apply(%__MODULE__{} = state, %PodcastUnliked{} = event) do - likes = state.podcast_likes || %{} - - case Map.get(likes, event.rss_source_feed) do + case Map.get(state.podcast_likes, event.rss_source_feed) do nil -> state like -> updated = Map.put(like, :unliked_at, event.unliked_at) - %{state | podcast_likes: Map.put(likes, event.rss_source_feed, updated)} + %{state | podcast_likes: Map.put(state.podcast_likes, event.rss_source_feed, updated)} end end def apply(%__MODULE__{} = state, %EpisodeLiked{} = event) do - likes = state.episode_likes || %{} - updated = %{ liked_at: event.liked_at, unliked_at: nil, @@ -168,20 +162,18 @@ defmodule BaladosSyncCore.Aggregates.Like do %{ state | user_id: event.user_id, - episode_likes: Map.put(likes, event.rss_source_item, updated) + episode_likes: Map.put(state.episode_likes, event.rss_source_item, updated) } end def apply(%__MODULE__{} = state, %EpisodeUnliked{} = event) do - likes = state.episode_likes || %{} - - case Map.get(likes, event.rss_source_item) do + case Map.get(state.episode_likes, event.rss_source_item) do nil -> state like -> updated = Map.put(like, :unliked_at, event.unliked_at) - %{state | episode_likes: Map.put(likes, event.rss_source_item, updated)} + %{state | episode_likes: Map.put(state.episode_likes, event.rss_source_item, updated)} end end @@ -195,7 +187,4 @@ defmodule BaladosSyncCore.Aggregates.Like do end def apply(%__MODULE__{} = state, _event), do: state - - defp get_in_map(nil, _key), do: nil - defp get_in_map(map, key), do: Map.get(map, key) end diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex index b225447d..f976a778 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex @@ -18,18 +18,30 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do alias BaladosSyncProjections.Schemas.UserLike project(%PodcastLiked{} = event, _metadata, fn multi -> + Logger.info( + "[LikeProjector] PodcastLiked: user=#{event.user_id}, feed=#{event.rss_source_feed}" + ) + Ecto.Multi.run(multi, :like_podcast, fn repo, _changes -> upsert_like(repo, event.user_id, event.rss_source_feed, nil, event.liked_at) end) end) project(%PodcastUnliked{} = event, _metadata, fn multi -> + Logger.info( + "[LikeProjector] PodcastUnliked: user=#{event.user_id}, feed=#{event.rss_source_feed}" + ) + Ecto.Multi.run(multi, :unlike_podcast, fn repo, _changes -> unlike(repo, event.user_id, event.rss_source_feed, nil, event.unliked_at) end) end) project(%EpisodeLiked{} = event, _metadata, fn multi -> + Logger.info( + "[LikeProjector] EpisodeLiked: user=#{event.user_id}, feed=#{event.rss_source_feed}, item=#{event.rss_source_item}" + ) + Ecto.Multi.run(multi, :like_episode, fn repo, _changes -> upsert_like( repo, @@ -42,42 +54,72 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do end) project(%EpisodeUnliked{} = event, _metadata, fn multi -> + Logger.info( + "[LikeProjector] EpisodeUnliked: user=#{event.user_id}, feed=#{event.rss_source_feed}, item=#{event.rss_source_item}" + ) + Ecto.Multi.run(multi, :unlike_episode, fn repo, _changes -> unlike(repo, event.user_id, event.rss_source_feed, event.rss_source_item, event.unliked_at) end) end) project(%LikeCheckpoint{} = event, _metadata, fn multi -> + Logger.info("[LikeProjector] LikeCheckpoint: user=#{event.user_id}") + Ecto.Multi.run(multi, :checkpoint, fn repo, _changes -> # Delete all existing likes for this user from(ul in UserLike, where: ul.user_id == ^event.user_id) |> repo.delete_all() - # Re-insert podcast likes + # Re-insert podcast likes with error propagation podcast_likes = event.podcast_likes || %{} - for {feed, like_data} <- podcast_likes do - upsert_like(repo, event.user_id, feed, nil, like_data.liked_at) - - if like_data.unliked_at do - unlike(repo, event.user_id, feed, nil, like_data.unliked_at) - end + with :ok <- replay_podcast_likes(repo, event.user_id, podcast_likes), + :ok <- replay_episode_likes(repo, event.user_id, event.episode_likes || %{}) do + Logger.debug("[LikeProjector] Checkpoint applied successfully for user=#{event.user_id}") + {:ok, :checkpoint_applied} end + end) + end) - # Re-insert episode likes - episode_likes = event.episode_likes || %{} - - for {item, like_data} <- episode_likes do - upsert_like(repo, event.user_id, like_data.rss_source_feed, item, like_data.liked_at) - - if like_data.unliked_at do - unlike(repo, event.user_id, like_data.rss_source_feed, item, like_data.unliked_at) - end + defp replay_podcast_likes(repo, user_id, podcast_likes) do + Enum.reduce_while(podcast_likes, :ok, fn {feed, like_data}, :ok -> + with {:ok, _} <- upsert_like(repo, user_id, feed, nil, like_data.liked_at), + {:ok, _} <- maybe_unlike(repo, user_id, feed, nil, like_data.unliked_at) do + {:cont, :ok} + else + {:error, reason} -> + Logger.error( + "[LikeProjector] Checkpoint failed for podcast feed=#{feed}: #{inspect(reason)}" + ) + + {:halt, {:error, reason}} end + end) + end - {:ok, :checkpoint_applied} + defp replay_episode_likes(repo, user_id, episode_likes) do + Enum.reduce_while(episode_likes, :ok, fn {item, like_data}, :ok -> + with {:ok, _} <- + upsert_like(repo, user_id, like_data.rss_source_feed, item, like_data.liked_at), + {:ok, _} <- + maybe_unlike(repo, user_id, like_data.rss_source_feed, item, like_data.unliked_at) do + {:cont, :ok} + else + {:error, reason} -> + Logger.error( + "[LikeProjector] Checkpoint failed for episode item=#{item}: #{inspect(reason)}" + ) + + {:halt, {:error, reason}} + end end) - end) + end + + defp maybe_unlike(_repo, _user_id, _feed, _item, nil), do: {:ok, nil} + + defp maybe_unlike(repo, user_id, feed, item, unliked_at), + do: unlike(repo, user_id, feed, item, unliked_at) defp upsert_like(repo, user_id, feed, item, liked_at) do existing = find_like(repo, user_id, feed, item) diff --git a/apps/balados_sync_projections/test/balados_sync_projections/projectors/like_projector_test.exs b/apps/balados_sync_projections/test/balados_sync_projections/projectors/like_projector_test.exs new file mode 100644 index 00000000..5020ce19 --- /dev/null +++ b/apps/balados_sync_projections/test/balados_sync_projections/projectors/like_projector_test.exs @@ -0,0 +1,277 @@ +defmodule BaladosSyncProjections.Projectors.LikeProjectorTest do + @moduledoc """ + Tests for LikeProjector logic. + + Verifies that like/unlike events are correctly projected to the user_likes table. + """ + + use BaladosSyncProjections.ProjectorTestCase + + import Ecto.Query + + describe "PodcastLiked projection" do + test "creates like from event" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + liked_at = now() + + event = %PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: liked_at, + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_event(event) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like != nil + assert like.liked_at == liked_at + assert like.unliked_at == nil + assert like.rss_source_item == nil + end + + test "re-like after unlike clears unliked_at" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + liked_at = now() + unliked_at = DateTime.add(liked_at, 3600, :second) + re_liked_at = DateTime.add(unliked_at, 3600, :second) + + assert {:ok, _} = + apply_event(%PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: liked_at, + timestamp: now(), + event_infos: %{} + }) + + assert {:ok, _} = + apply_event(%PodcastUnliked{ + user_id: user_id, + rss_source_feed: feed, + unliked_at: unliked_at, + timestamp: now(), + event_infos: %{} + }) + + assert {:ok, _} = + apply_event(%PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: re_liked_at, + timestamp: now(), + event_infos: %{} + }) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.liked_at == re_liked_at + assert like.unliked_at == nil + end + + test "isolates likes between users" do + user1 = uuid() + user2 = uuid() + feed = encode_feed("https://example.com/podcast.xml") + + for user <- [user1, user2] do + assert {:ok, _} = + apply_event(%PodcastLiked{ + user_id: user, + rss_source_feed: feed, + liked_at: now(), + timestamp: now(), + event_infos: %{} + }) + end + + likes = from(ul in UserLike, where: ul.rss_source_feed == ^feed) |> ProjectionsRepo.all() + assert length(likes) == 2 + end + end + + describe "PodcastUnliked projection" do + test "sets unliked_at on existing like" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + liked_at = now() + unliked_at = DateTime.add(liked_at, 3600, :second) + + assert {:ok, _} = + apply_event(%PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: liked_at, + timestamp: now(), + event_infos: %{} + }) + + assert {:ok, _} = + apply_event(%PodcastUnliked{ + user_id: user_id, + rss_source_feed: feed, + unliked_at: unliked_at, + timestamp: now(), + event_infos: %{} + }) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.unliked_at == unliked_at + end + + test "no-op when no existing like" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + + assert {:ok, nil} = + apply_event(%PodcastUnliked{ + user_id: user_id, + rss_source_feed: feed, + unliked_at: now(), + timestamp: now(), + event_infos: %{} + }) + end + end + + describe "EpisodeLiked projection" do + test "creates episode like" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + item = encode_item("guid-123", "https://example.com/ep1.mp3") + liked_at = now() + + assert {:ok, _} = + apply_event(%EpisodeLiked{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: liked_at, + timestamp: now(), + event_infos: %{} + }) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_item: item) + assert like != nil + assert like.rss_source_feed == feed + assert like.liked_at == liked_at + end + end + + describe "EpisodeUnliked projection" do + test "sets unliked_at on episode like" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + item = encode_item("guid-123", "https://example.com/ep1.mp3") + liked_at = now() + unliked_at = DateTime.add(liked_at, 3600, :second) + + assert {:ok, _} = + apply_event(%EpisodeLiked{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: liked_at, + timestamp: now(), + event_infos: %{} + }) + + assert {:ok, _} = + apply_event(%EpisodeUnliked{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + unliked_at: unliked_at, + timestamp: now(), + event_infos: %{} + }) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_item: item) + assert like.unliked_at == unliked_at + end + end + + describe "LikeCheckpoint projection" do + test "replaces all likes with checkpoint data" do + user_id = uuid() + feed1 = encode_feed("https://podcast1.example.com/feed.xml") + feed2 = encode_feed("https://podcast2.example.com/feed.xml") + liked_at = now() + + # Create initial likes + assert {:ok, _} = + apply_event(%PodcastLiked{ + user_id: user_id, + rss_source_feed: feed1, + liked_at: liked_at, + timestamp: now(), + event_infos: %{} + }) + + # Apply checkpoint with different data + feed3 = encode_feed("https://podcast3.example.com/feed.xml") + + assert {:ok, _} = + apply_event(%LikeCheckpoint{ + user_id: user_id, + podcast_likes: %{ + feed2 => %{liked_at: liked_at, unliked_at: nil}, + feed3 => %{liked_at: liked_at, unliked_at: nil} + }, + episode_likes: %{}, + timestamp: now() + }) + + # feed1 should be gone, feed2 and feed3 should exist + likes = from(ul in UserLike, where: ul.user_id == ^user_id) |> ProjectionsRepo.all() + assert length(likes) == 2 + feeds = Enum.map(likes, & &1.rss_source_feed) |> Enum.sort() + assert feeds == Enum.sort([feed2, feed3]) + end + + test "checkpoint with unliked entries preserves unliked_at" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + liked_at = now() + unliked_at = DateTime.add(liked_at, 3600, :second) + + assert {:ok, _} = + apply_event(%LikeCheckpoint{ + user_id: user_id, + podcast_likes: %{ + feed => %{liked_at: liked_at, unliked_at: unliked_at} + }, + episode_likes: %{}, + timestamp: now() + }) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.liked_at == liked_at + assert like.unliked_at == unliked_at + end + + test "checkpoint with episode likes" do + user_id = uuid() + feed = encode_feed("https://example.com/podcast.xml") + item = encode_item("guid-1", "https://example.com/ep1.mp3") + liked_at = now() + + assert {:ok, _} = + apply_event(%LikeCheckpoint{ + user_id: user_id, + podcast_likes: %{}, + episode_likes: %{ + item => %{liked_at: liked_at, unliked_at: nil, rss_source_feed: feed} + }, + timestamp: now() + }) + + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_item: item) + assert like != nil + assert like.rss_source_feed == feed + assert like.liked_at == liked_at + end + end +end diff --git a/apps/balados_sync_projections/test/support/projector_test_case.ex b/apps/balados_sync_projections/test/support/projector_test_case.ex index 1ad6daec..2b6259e3 100644 --- a/apps/balados_sync_projections/test/support/projector_test_case.ex +++ b/apps/balados_sync_projections/test/support/projector_test_case.ex @@ -77,6 +77,12 @@ defmodule BaladosSyncProjections.ProjectorTestCase do CollectionVisibilityChanged, FeedAddedToCollection, FeedRemovedFromCollection, + # Likes + PodcastLiked, + PodcastUnliked, + EpisodeLiked, + EpisodeUnliked, + LikeCheckpoint, # Public events / Privacy PrivacyChanged, EventsRemoved @@ -91,7 +97,8 @@ defmodule BaladosSyncProjections.ProjectorTestCase do Collection, CollectionSubscription, PublicEvent, - UserPrivacy + UserPrivacy, + UserLike } end end @@ -160,6 +167,21 @@ defmodule BaladosSyncProjections.ProjectorTestCase do %BaladosSyncCore.Events.CollectionFeedReordered{} -> apply_collection_feed_reordered(event, ProjectionsRepo) + %BaladosSyncCore.Events.PodcastLiked{} -> + apply_podcast_liked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.PodcastUnliked{} -> + apply_podcast_unliked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.EpisodeLiked{} -> + apply_episode_liked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.EpisodeUnliked{} -> + apply_episode_unliked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.LikeCheckpoint{} -> + apply_like_checkpoint(event, ProjectionsRepo) + _ -> {:error, {:unsupported_event, event.__struct__}} end @@ -548,6 +570,154 @@ defmodule BaladosSyncProjections.ProjectorTestCase do {:ok, %{reordered: results}} end + # ============================================================================ + # Like Projector Logic + # ============================================================================ + + defp apply_podcast_liked(event, repo) do + import Ecto.Query + alias BaladosSyncProjections.Schemas.UserLike + + existing = + from(ul in UserLike, + where: + ul.user_id == ^event.user_id and ul.rss_source_feed == ^event.rss_source_feed and + is_nil(ul.rss_source_item) + ) + |> repo.one() + + case existing do + nil -> + repo.insert(%UserLike{ + user_id: event.user_id, + rss_source_feed: event.rss_source_feed, + rss_source_item: nil, + liked_at: event.liked_at, + unliked_at: nil + }) + + like -> + like + |> Ecto.Changeset.change(%{liked_at: event.liked_at, unliked_at: nil}) + |> repo.update() + end + end + + defp apply_podcast_unliked(event, repo) do + import Ecto.Query + alias BaladosSyncProjections.Schemas.UserLike + + existing = + from(ul in UserLike, + where: + ul.user_id == ^event.user_id and ul.rss_source_feed == ^event.rss_source_feed and + is_nil(ul.rss_source_item) + ) + |> repo.one() + + case existing do + nil -> + {:ok, nil} + + like -> + like + |> Ecto.Changeset.change(%{unliked_at: event.unliked_at}) + |> repo.update() + end + end + + defp apply_episode_liked(event, repo) do + import Ecto.Query + alias BaladosSyncProjections.Schemas.UserLike + + existing = + from(ul in UserLike, + where: + ul.user_id == ^event.user_id and ul.rss_source_feed == ^event.rss_source_feed and + ul.rss_source_item == ^event.rss_source_item + ) + |> repo.one() + + case existing do + nil -> + repo.insert(%UserLike{ + user_id: event.user_id, + rss_source_feed: event.rss_source_feed, + rss_source_item: event.rss_source_item, + liked_at: event.liked_at, + unliked_at: nil + }) + + like -> + like + |> Ecto.Changeset.change(%{liked_at: event.liked_at, unliked_at: nil}) + |> repo.update() + end + end + + defp apply_episode_unliked(event, repo) do + import Ecto.Query + alias BaladosSyncProjections.Schemas.UserLike + + existing = + from(ul in UserLike, + where: + ul.user_id == ^event.user_id and ul.rss_source_feed == ^event.rss_source_feed and + ul.rss_source_item == ^event.rss_source_item + ) + |> repo.one() + + case existing do + nil -> + {:ok, nil} + + like -> + like + |> Ecto.Changeset.change(%{unliked_at: event.unliked_at}) + |> repo.update() + end + end + + defp apply_like_checkpoint(event, repo) do + import Ecto.Query + alias BaladosSyncProjections.Schemas.UserLike + + # Delete all existing likes for this user + from(ul in UserLike, where: ul.user_id == ^event.user_id) + |> repo.delete_all() + + podcast_likes = event.podcast_likes || %{} + episode_likes = event.episode_likes || %{} + + # Re-insert podcast likes + for {feed, like_data} <- podcast_likes do + attrs = %{ + user_id: event.user_id, + rss_source_feed: feed, + rss_source_item: nil, + liked_at: like_data.liked_at, + unliked_at: like_data[:unliked_at] + } + + repo.insert!(%UserLike{} |> Ecto.Changeset.change(attrs)) + end + + # Re-insert episode likes + for {item, like_data} <- episode_likes do + attrs = %{ + user_id: event.user_id, + rss_source_feed: like_data.rss_source_feed, + rss_source_item: item, + liked_at: like_data.liked_at, + unliked_at: like_data[:unliked_at] + } + + repo.insert!(%UserLike{} |> Ecto.Changeset.change(attrs)) + end + + {:ok, :checkpoint_applied} + end + # ============================================================================ # DateTime Helpers # ============================================================================ diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index 564edc06..c967b34e 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -388,6 +388,14 @@ defmodule BaladosSyncWeb.SyncController do end # Sync likes with LWW + # + # NOTE: Like subscriptions and play statuses, synced likes are written directly + # to the projections table rather than dispatched as CQRS commands. This is an + # intentional trade-off: sync data arrives already validated from the client's + # event history. The Like aggregate state won't reflect synced likes, so a + # subsequent LikePodcast command on a podcast already liked via sync may + # re-emit a PodcastLiked event (idempotency gap between sync and CQRS paths). + # This matches the established pattern for subscriptions/plays in this codebase. defp sync_likes(multi, _user_id, likes, _now) when likes == [], do: multi defp sync_likes(multi, user_id, likes, now) do diff --git a/apps/balados_sync_web/lib/balados_sync_web/queries.ex b/apps/balados_sync_web/lib/balados_sync_web/queries.ex index 9fc75038..22767b81 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/queries.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/queries.ex @@ -119,6 +119,9 @@ defmodule BaladosSyncWeb.Queries do } end + # Returns only active likes (unliked_at IS NULL) for the initial full sync payload. + # Incremental sync (get_likes_since) includes unliked entries so clients can + # process unlike events that happened since their last sync. def get_user_likes(user_id) do from(ul in UserLike, where: ul.user_id == ^user_id and is_nil(ul.unliked_at), diff --git a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs index eec0d393..4ef34993 100644 --- a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs +++ b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs @@ -70,7 +70,7 @@ defmodule BaladosSyncWeb.LikeControllerTest do end test "successfully unlikes a podcast", %{conn: conn, user_id: user_id} do - # First like the podcast + # Dispatch command only - unlike dispatches its own command regardless of projection state Dispatcher.dispatch(%LikePodcast{ user_id: user_id, rss_source_feed: "dGVzdC1mZWVk", @@ -78,8 +78,6 @@ defmodule BaladosSyncWeb.LikeControllerTest do event_infos: %{} }) - Process.sleep(50) - conn = conn |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.write"]) From f60d0fc5f00f7abcbb21a2a1b7e164965302a221 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 15:12:26 +0100 Subject: [PATCH 3/9] fix: address review round 2 for PR #255 MUST-FIX: - Normalize string keys in LikeCheckpoint apply/2 (aggregate) and projector: after JSON deserialization from event store, nested map keys become strings ("liked_at") instead of atoms (:liked_at). Added atomize_keys helpers in both aggregate and projector. - Added test verifying idempotency works after checkpoint with string keys SHOULD-FIX: - Unlike handlers in PopularityProjector now early-return {:ok, nil} when no popularity record exists (avoids creating ghost records) - Enhanced sync_likes comment with specific duplicate event scenario Co-Authored-By: Claude Opus 4.6 --- .../lib/balados_sync_core/aggregates/like.ex | 24 +++++- .../aggregates/like_test.exs | 32 +++++++ .../projectors/like_projector.ex | 23 ++++- .../projectors/popularity_projector.ex | 85 ++++++++++--------- .../controllers/sync_controller.ex | 9 +- 5 files changed, 123 insertions(+), 50 deletions(-) diff --git a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex index 665a6cdc..e9b2e361 100644 --- a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex +++ b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex @@ -178,13 +178,33 @@ defmodule BaladosSyncCore.Aggregates.Like do end def apply(%__MODULE__{} = state, %LikeCheckpoint{} = event) do + # Normalize keys: after JSON serialization/deserialization (event store replay), + # nested map keys may be strings instead of atoms (e.g. "liked_at" vs :liked_at). %{ state | user_id: event.user_id, - podcast_likes: event.podcast_likes || %{}, - episode_likes: event.episode_likes || %{} + podcast_likes: normalize_likes_map(event.podcast_likes || %{}), + episode_likes: normalize_likes_map(event.episode_likes || %{}) } end def apply(%__MODULE__{} = state, _event), do: state + + # Normalize nested like data maps to ensure atom keys after JSON deserialization. + # The event store serializes events as JSON, so after replay, nested maps have + # string keys (e.g. "liked_at") instead of atoms (:liked_at). + defp normalize_likes_map(likes) when is_map(likes) do + Map.new(likes, fn {key, value} -> {key, atomize_keys(value)} end) + end + + defp normalize_likes_map(_), do: %{} + + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} + {key, value} -> {key, value} + end) + end + + defp atomize_keys(value), do: value end diff --git a/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs b/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs index 0bd467c1..03334b60 100644 --- a/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs +++ b/apps/balados_sync_core/test/balados_sync_core/aggregates/like_test.exs @@ -298,6 +298,38 @@ defmodule BaladosSyncCore.Aggregates.LikeTest do assert new_state.episode_likes == episode_likes end + test "LikeCheckpoint normalizes string keys from JSON deserialization" do + state = %Like{user_id: nil} + + # Simulate what the event store returns after JSON deserialization: + # nested map keys become strings instead of atoms + event = %LikeCheckpoint{ + user_id: "user-1", + podcast_likes: %{ + "feed-1" => %{"liked_at" => ~U[2024-01-01 00:00:00Z], "unliked_at" => nil} + }, + episode_likes: %{ + "item-1" => %{ + "liked_at" => ~U[2024-01-01 00:00:00Z], + "unliked_at" => nil, + "rss_source_feed" => "feed-1" + } + } + } + + new_state = Like.apply(state, event) + + # After normalization, keys should be atoms + assert new_state.podcast_likes["feed-1"].liked_at == ~U[2024-01-01 00:00:00Z] + assert new_state.podcast_likes["feed-1"].unliked_at == nil + assert new_state.episode_likes["item-1"].liked_at == ~U[2024-01-01 00:00:00Z] + assert new_state.episode_likes["item-1"].rss_source_feed == "feed-1" + + # Idempotency should work after checkpoint replay with string keys + cmd = %LikePodcast{user_id: "user-1", rss_source_feed: "feed-1"} + assert [] = Like.execute(new_state, cmd) + end + test "unknown events are ignored" do state = %Like{user_id: "user-1", podcast_likes: %{}, episode_likes: %{}} new_state = Like.apply(state, %{some: "unknown event"}) diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex index f976a778..e1138556 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex @@ -71,11 +71,12 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do from(ul in UserLike, where: ul.user_id == ^event.user_id) |> repo.delete_all() - # Re-insert podcast likes with error propagation - podcast_likes = event.podcast_likes || %{} + # Normalize keys: after JSON deserialization, nested map keys may be strings + podcast_likes = normalize_likes_map(event.podcast_likes || %{}) + episode_likes = normalize_likes_map(event.episode_likes || %{}) with :ok <- replay_podcast_likes(repo, event.user_id, podcast_likes), - :ok <- replay_episode_likes(repo, event.user_id, event.episode_likes || %{}) do + :ok <- replay_episode_likes(repo, event.user_id, episode_likes) do Logger.debug("[LikeProjector] Checkpoint applied successfully for user=#{event.user_id}") {:ok, :checkpoint_applied} end @@ -172,4 +173,20 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do ) |> repo.one() end + + # Normalize nested like data maps to ensure atom keys after JSON deserialization. + defp normalize_likes_map(likes) when is_map(likes) do + Map.new(likes, fn {key, value} -> {key, atomize_keys(value)} end) + end + + defp normalize_likes_map(_), do: %{} + + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} + {key, value} -> {key, value} + end) + end + + defp atomize_keys(value), do: value end diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex index bddff8aa..053a3b32 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex @@ -416,22 +416,24 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do project(%PodcastUnliked{} = event, _metadata, fn multi -> Ecto.Multi.run(multi, :podcast_unliked, fn repo, _changes -> - popularity = - repo.get(PodcastPopularity, event.rss_source_feed) || - %PodcastPopularity{rss_source_feed: event.rss_source_feed} - - updated = %{ - popularity - | score: max(popularity.score - @score_like, 0), - likes: max(popularity.likes - 1, 0), - likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) - } + case repo.get(PodcastPopularity, event.rss_source_feed) do + nil -> + {:ok, nil} + + popularity -> + updated = %{ + popularity + | score: max(popularity.score - @score_like, 0), + likes: max(popularity.likes - 1, 0), + likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) + } - repo.insert_or_update( - PodcastPopularity.changeset(updated, %{}), - on_conflict: :replace_all, - conflict_target: :rss_source_feed - ) + repo.insert_or_update( + PodcastPopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end end) end) @@ -491,39 +493,40 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do project(%EpisodeUnliked{} = event, _metadata, fn multi -> multi = Ecto.Multi.run(multi, :episode_unliked, fn repo, _changes -> - popularity = - repo.get(EpisodePopularity, event.rss_source_item) || - %EpisodePopularity{ - rss_source_item: event.rss_source_item, - rss_source_feed: event.rss_source_feed + case repo.get(EpisodePopularity, event.rss_source_item) do + nil -> + {:ok, nil} + + popularity -> + updated = %{ + popularity + | score: max(popularity.score - @score_like, 0), + likes: max(popularity.likes - 1, 0), + likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) } - updated = %{ - popularity - | score: max(popularity.score - @score_like, 0), - likes: max(popularity.likes - 1, 0), - likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) - } - - repo.insert_or_update( - EpisodePopularity.changeset(updated, %{}), - on_conflict: :replace_all, - conflict_target: :rss_source_item - ) + repo.insert_or_update( + EpisodePopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_item + ) + end end) Ecto.Multi.run(multi, :podcast_score_from_episode_unlike, fn repo, _changes -> - popularity = - repo.get(PodcastPopularity, event.rss_source_feed) || - %PodcastPopularity{rss_source_feed: event.rss_source_feed} + case repo.get(PodcastPopularity, event.rss_source_feed) do + nil -> + {:ok, nil} - updated = %{popularity | score: max(popularity.score - @score_like, 0)} + popularity -> + updated = %{popularity | score: max(popularity.score - @score_like, 0)} - repo.insert_or_update( - PodcastPopularity.changeset(updated, %{}), - on_conflict: :replace_all, - conflict_target: :rss_source_feed - ) + repo.insert_or_update( + PodcastPopularity.changeset(updated, %{}), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end end) end) diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index c967b34e..93e8a424 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -392,10 +392,11 @@ defmodule BaladosSyncWeb.SyncController do # NOTE: Like subscriptions and play statuses, synced likes are written directly # to the projections table rather than dispatched as CQRS commands. This is an # intentional trade-off: sync data arrives already validated from the client's - # event history. The Like aggregate state won't reflect synced likes, so a - # subsequent LikePodcast command on a podcast already liked via sync may - # re-emit a PodcastLiked event (idempotency gap between sync and CQRS paths). - # This matches the established pattern for subscriptions/plays in this codebase. + # event history. The Like aggregate state won't reflect synced likes, so if a + # device calls POST /api/v1/likes for a feed it already synced as liked, the + # aggregate will re-emit a PodcastLiked event (projection no-ops via upsert, + # but the event store accumulates a duplicate event). Low impact since likes + # are lightweight events. Matches established pattern for subscriptions/plays. defp sync_likes(multi, _user_id, likes, _now) when likes == [], do: multi defp sync_likes(multi, user_id, likes, now) do From 789b99f7d81fffcb36f3fbaf215968f38f100b24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 17:04:58 +0100 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20address=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20shared=20LikeNormalizer,=20bulk=20checkpoint,=20pop?= =?UTF-8?q?ularity=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract LikeNormalizer module shared by aggregate and projector (removes duplication) - Replace N+1 checkpoint queries with bulk repo.insert_all - Add PopularityProjector tests for like/unlike handlers (8 tests) - Pass attrs properly in test helpers for Ecto change tracking Co-Authored-By: Claude Opus 4.6 --- .../lib/balados_sync_core/aggregates/like.ex | 27 +-- .../lib/balados_sync_core/like_normalizer.ex | 33 +++ .../projectors/like_projector.ex | 103 ++++---- .../projectors/popularity_projector_test.exs | 222 ++++++++++++++++++ .../test/support/projector_test_case.ex | 169 ++++++++++++- 5 files changed, 470 insertions(+), 84 deletions(-) create mode 100644 apps/balados_sync_core/lib/balados_sync_core/like_normalizer.ex create mode 100644 apps/balados_sync_projections/test/balados_sync_projections/projectors/popularity_projector_test.exs diff --git a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex index e9b2e361..55125976 100644 --- a/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex +++ b/apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex @@ -40,6 +40,8 @@ defmodule BaladosSyncCore.Aggregates.Like do LikeCheckpoint } + alias BaladosSyncCore.LikeNormalizer + # LikePodcast - idempotent: no-op if already liked def execute(%__MODULE__{} = state, %LikePodcast{} = cmd) do case Map.get(state.podcast_likes, cmd.rss_source_feed) do @@ -178,33 +180,14 @@ defmodule BaladosSyncCore.Aggregates.Like do end def apply(%__MODULE__{} = state, %LikeCheckpoint{} = event) do - # Normalize keys: after JSON serialization/deserialization (event store replay), - # nested map keys may be strings instead of atoms (e.g. "liked_at" vs :liked_at). + # Normalize keys: after JSON deserialization, nested map keys may be strings %{ state | user_id: event.user_id, - podcast_likes: normalize_likes_map(event.podcast_likes || %{}), - episode_likes: normalize_likes_map(event.episode_likes || %{}) + podcast_likes: LikeNormalizer.normalize(event.podcast_likes || %{}), + episode_likes: LikeNormalizer.normalize(event.episode_likes || %{}) } end def apply(%__MODULE__{} = state, _event), do: state - - # Normalize nested like data maps to ensure atom keys after JSON deserialization. - # The event store serializes events as JSON, so after replay, nested maps have - # string keys (e.g. "liked_at") instead of atoms (:liked_at). - defp normalize_likes_map(likes) when is_map(likes) do - Map.new(likes, fn {key, value} -> {key, atomize_keys(value)} end) - end - - defp normalize_likes_map(_), do: %{} - - defp atomize_keys(map) when is_map(map) do - Map.new(map, fn - {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} - {key, value} -> {key, value} - end) - end - - defp atomize_keys(value), do: value end diff --git a/apps/balados_sync_core/lib/balados_sync_core/like_normalizer.ex b/apps/balados_sync_core/lib/balados_sync_core/like_normalizer.ex new file mode 100644 index 00000000..fd2a87b9 --- /dev/null +++ b/apps/balados_sync_core/lib/balados_sync_core/like_normalizer.ex @@ -0,0 +1,33 @@ +defmodule BaladosSyncCore.LikeNormalizer do + @moduledoc """ + Normalizes nested like data maps to ensure atom keys after JSON deserialization. + + The event store serializes events as JSON, so after replay, nested maps have + string keys (e.g. "liked_at") instead of atoms (:liked_at). This module + provides a shared normalization function used by both the Like aggregate + and the LikeProjector. + """ + + @doc """ + Normalize a map of like data entries, converting string keys to atoms. + + ## Examples + + iex> normalize(%{"feed-1" => %{"liked_at" => ~U[2024-01-01 00:00:00Z]}}) + %{"feed-1" => %{liked_at: ~U[2024-01-01 00:00:00Z]}} + """ + def normalize(likes) when is_map(likes) do + Map.new(likes, fn {key, value} -> {key, atomize_keys(value)} end) + end + + def normalize(_), do: %{} + + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} + {key, value} -> {key, value} + end) + end + + defp atomize_keys(value), do: value +end diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex index e1138556..4e69cc8c 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex @@ -15,6 +15,7 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do LikeCheckpoint } + alias BaladosSyncCore.LikeNormalizer alias BaladosSyncProjections.Schemas.UserLike project(%PodcastLiked{} = event, _metadata, fn multi -> @@ -72,55 +73,51 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do |> repo.delete_all() # Normalize keys: after JSON deserialization, nested map keys may be strings - podcast_likes = normalize_likes_map(event.podcast_likes || %{}) - episode_likes = normalize_likes_map(event.episode_likes || %{}) - - with :ok <- replay_podcast_likes(repo, event.user_id, podcast_likes), - :ok <- replay_episode_likes(repo, event.user_id, episode_likes) do - Logger.debug("[LikeProjector] Checkpoint applied successfully for user=#{event.user_id}") - {:ok, :checkpoint_applied} + podcast_likes = LikeNormalizer.normalize(event.podcast_likes) + episode_likes = LikeNormalizer.normalize(event.episode_likes) + + # Build all entries for bulk insert (no N+1 queries since we deleted everything above) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + podcast_entries = + Enum.map(podcast_likes, fn {feed, like_data} -> + %{ + user_id: event.user_id, + rss_source_feed: feed, + rss_source_item: nil, + liked_at: like_data.liked_at, + unliked_at: like_data[:unliked_at], + inserted_at: now, + updated_at: now + } + end) + + episode_entries = + Enum.map(episode_likes, fn {item, like_data} -> + %{ + user_id: event.user_id, + rss_source_feed: like_data.rss_source_feed, + rss_source_item: item, + liked_at: like_data.liked_at, + unliked_at: like_data[:unliked_at], + inserted_at: now, + updated_at: now + } + end) + + entries = podcast_entries ++ episode_entries + + if entries != [] do + repo.insert_all(UserLike, entries) end - end) - end) - defp replay_podcast_likes(repo, user_id, podcast_likes) do - Enum.reduce_while(podcast_likes, :ok, fn {feed, like_data}, :ok -> - with {:ok, _} <- upsert_like(repo, user_id, feed, nil, like_data.liked_at), - {:ok, _} <- maybe_unlike(repo, user_id, feed, nil, like_data.unliked_at) do - {:cont, :ok} - else - {:error, reason} -> - Logger.error( - "[LikeProjector] Checkpoint failed for podcast feed=#{feed}: #{inspect(reason)}" - ) - - {:halt, {:error, reason}} - end - end) - end + Logger.debug( + "[LikeProjector] Checkpoint applied: #{length(entries)} likes for user=#{event.user_id}" + ) - defp replay_episode_likes(repo, user_id, episode_likes) do - Enum.reduce_while(episode_likes, :ok, fn {item, like_data}, :ok -> - with {:ok, _} <- - upsert_like(repo, user_id, like_data.rss_source_feed, item, like_data.liked_at), - {:ok, _} <- - maybe_unlike(repo, user_id, like_data.rss_source_feed, item, like_data.unliked_at) do - {:cont, :ok} - else - {:error, reason} -> - Logger.error( - "[LikeProjector] Checkpoint failed for episode item=#{item}: #{inspect(reason)}" - ) - - {:halt, {:error, reason}} - end + {:ok, :checkpoint_applied} end) - end - - defp maybe_unlike(_repo, _user_id, _feed, _item, nil), do: {:ok, nil} - - defp maybe_unlike(repo, user_id, feed, item, unliked_at), - do: unlike(repo, user_id, feed, item, unliked_at) + end) defp upsert_like(repo, user_id, feed, item, liked_at) do existing = find_like(repo, user_id, feed, item) @@ -173,20 +170,4 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do ) |> repo.one() end - - # Normalize nested like data maps to ensure atom keys after JSON deserialization. - defp normalize_likes_map(likes) when is_map(likes) do - Map.new(likes, fn {key, value} -> {key, atomize_keys(value)} end) - end - - defp normalize_likes_map(_), do: %{} - - defp atomize_keys(map) when is_map(map) do - Map.new(map, fn - {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} - {key, value} -> {key, value} - end) - end - - defp atomize_keys(value), do: value end diff --git a/apps/balados_sync_projections/test/balados_sync_projections/projectors/popularity_projector_test.exs b/apps/balados_sync_projections/test/balados_sync_projections/projectors/popularity_projector_test.exs new file mode 100644 index 00000000..1a883dc0 --- /dev/null +++ b/apps/balados_sync_projections/test/balados_sync_projections/projectors/popularity_projector_test.exs @@ -0,0 +1,222 @@ +defmodule BaladosSyncProjections.Projectors.PopularityProjectorTest do + @moduledoc """ + Tests for PopularityProjector like/unlike event handlers. + + Uses ProjectorTestCase to apply events directly to the database, + verifying popularity projections are updated correctly. + """ + + use BaladosSyncProjections.ProjectorTestCase + + describe "PodcastLiked" do + test "creates podcast popularity record on first like" do + feed = encode_feed("https://example.com/feed.xml") + user_id = uuid() + + event = %PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, pop} = apply_popularity_event(event) + assert pop.likes == 1 + assert pop.score == 7 + assert pop.likes_people == [user_id] + end + + test "increments likes for existing popularity record" do + feed = encode_feed("https://example.com/feed.xml") + + # First like + event1 = %PodcastLiked{ + user_id: uuid(), + rss_source_feed: feed, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_popularity_event(event1) + + # Second like from different user + user2 = uuid() + + event2 = %PodcastLiked{ + user_id: user2, + rss_source_feed: feed, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, pop} = apply_popularity_event(event2) + assert pop.likes == 2 + assert pop.score == 14 + assert user2 in pop.likes_people + end + end + + describe "PodcastUnliked" do + test "decrements likes and removes user from likes_people" do + feed = encode_feed("https://example.com/feed.xml") + user_id = uuid() + + # Like first + like_event = %PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_popularity_event(like_event) + + # Unlike + unlike_event = %PodcastUnliked{ + user_id: user_id, + rss_source_feed: feed, + unliked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, pop} = apply_popularity_event(unlike_event) + assert pop.likes == 0 + assert pop.score == 0 + refute user_id in pop.likes_people + end + + test "no-op when no popularity record exists" do + event = %PodcastUnliked{ + user_id: uuid(), + rss_source_feed: encode_feed("https://nonexistent.com/feed.xml"), + unliked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, nil} = apply_popularity_event(event) + end + + test "score never goes below zero" do + feed = encode_feed("https://example.com/feed.xml") + user_id = uuid() + + # Like then unlike twice (simulate edge case) + like_event = %PodcastLiked{ + user_id: user_id, + rss_source_feed: feed, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_popularity_event(like_event) + + unlike_event = %PodcastUnliked{ + user_id: user_id, + rss_source_feed: feed, + unliked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, pop1} = apply_popularity_event(unlike_event) + assert pop1.score == 0 + assert pop1.likes == 0 + + # Unlike again — score should stay at 0 + assert {:ok, pop2} = apply_popularity_event(unlike_event) + assert pop2.score == 0 + assert pop2.likes == 0 + end + end + + describe "EpisodeLiked" do + test "creates episode popularity and increments podcast score" do + feed = encode_feed("https://example.com/feed.xml") + item = encode_item("episode-1", "https://example.com/ep1.mp3") + user_id = uuid() + + event = %EpisodeLiked{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_popularity_event(event) + + # Check episode popularity + episode_pop = ProjectionsRepo.get(EpisodePopularity, item) + assert episode_pop.likes == 1 + assert episode_pop.score == 7 + assert user_id in episode_pop.likes_people + + # Check podcast score was also incremented + podcast_pop = ProjectionsRepo.get(PodcastPopularity, feed) + assert podcast_pop.score == 7 + end + end + + describe "EpisodeUnliked" do + test "decrements episode popularity and podcast score" do + feed = encode_feed("https://example.com/feed.xml") + item = encode_item("episode-1", "https://example.com/ep1.mp3") + user_id = uuid() + + # Like first + like_event = %EpisodeLiked{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + liked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_popularity_event(like_event) + + # Unlike + unlike_event = %EpisodeUnliked{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: item, + unliked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, _} = apply_popularity_event(unlike_event) + + # Check episode popularity + episode_pop = ProjectionsRepo.get(EpisodePopularity, item) + assert episode_pop.likes == 0 + assert episode_pop.score == 0 + refute user_id in episode_pop.likes_people + + # Check podcast score was also decremented + podcast_pop = ProjectionsRepo.get(PodcastPopularity, feed) + assert podcast_pop.score == 0 + end + + test "no-op when no episode popularity record exists" do + event = %EpisodeUnliked{ + user_id: uuid(), + rss_source_feed: encode_feed("https://example.com/feed.xml"), + rss_source_item: encode_item("nonexistent", "https://example.com/nope.mp3"), + unliked_at: now(), + timestamp: now(), + event_infos: %{} + } + + assert {:ok, nil} = apply_popularity_event(event) + end + end +end diff --git a/apps/balados_sync_projections/test/support/projector_test_case.ex b/apps/balados_sync_projections/test/support/projector_test_case.ex index 2b6259e3..60229eac 100644 --- a/apps/balados_sync_projections/test/support/projector_test_case.ex +++ b/apps/balados_sync_projections/test/support/projector_test_case.ex @@ -98,7 +98,9 @@ defmodule BaladosSyncProjections.ProjectorTestCase do CollectionSubscription, PublicEvent, UserPrivacy, - UserLike + UserLike, + PodcastPopularity, + EpisodePopularity } end end @@ -187,6 +189,33 @@ defmodule BaladosSyncProjections.ProjectorTestCase do end end + @doc """ + Apply an event to the popularity projections. + + Separate from `apply_event` because popularity projections are handled by + a different projector (PopularityProjector) and require different schemas. + """ + def apply_popularity_event(event) do + alias BaladosSyncProjections.ProjectionsRepo + + case event do + %BaladosSyncCore.Events.PodcastLiked{} -> + apply_popularity_podcast_liked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.PodcastUnliked{} -> + apply_popularity_podcast_unliked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.EpisodeLiked{} -> + apply_popularity_episode_liked(event, ProjectionsRepo) + + %BaladosSyncCore.Events.EpisodeUnliked{} -> + apply_popularity_episode_unliked(event, ProjectionsRepo) + + _ -> + {:error, {:unsupported_popularity_event, event.__struct__}} + end + end + # ============================================================================ # Test Helpers # ============================================================================ @@ -718,6 +747,144 @@ defmodule BaladosSyncProjections.ProjectorTestCase do {:ok, :checkpoint_applied} end + # ============================================================================ + # Popularity Projector Logic (Like events) + # ============================================================================ + + @score_like 7 + + defp apply_popularity_podcast_liked(event, repo) do + alias BaladosSyncProjections.Schemas.PodcastPopularity + + popularity = + repo.get(PodcastPopularity, event.rss_source_feed) || + %PodcastPopularity{rss_source_feed: event.rss_source_feed} + + attrs = %{ + rss_source_feed: event.rss_source_feed, + score: popularity.score + @score_like, + likes: popularity.likes + 1, + likes_people: add_recent_user(popularity.likes_people, event.user_id) + } + + repo.insert_or_update( + PodcastPopularity.changeset(popularity, attrs), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end + + defp apply_popularity_podcast_unliked(event, repo) do + alias BaladosSyncProjections.Schemas.PodcastPopularity + + case repo.get(PodcastPopularity, event.rss_source_feed) do + nil -> + {:ok, nil} + + popularity -> + attrs = %{ + score: max(popularity.score - @score_like, 0), + likes: max(popularity.likes - 1, 0), + likes_people: Enum.reject(popularity.likes_people || [], &(&1 == event.user_id)) + } + + repo.insert_or_update( + PodcastPopularity.changeset(popularity, attrs), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end + end + + defp apply_popularity_episode_liked(event, repo) do + alias BaladosSyncProjections.Schemas.{EpisodePopularity, PodcastPopularity} + + # Episode popularity + episode_pop = + repo.get(EpisodePopularity, event.rss_source_item) || + %EpisodePopularity{ + rss_source_item: event.rss_source_item, + rss_source_feed: event.rss_source_feed + } + + episode_attrs = %{ + rss_source_item: event.rss_source_item, + rss_source_feed: event.rss_source_feed, + score: episode_pop.score + @score_like, + likes: episode_pop.likes + 1, + likes_people: add_recent_user(episode_pop.likes_people, event.user_id) + } + + {:ok, _} = + repo.insert_or_update( + EpisodePopularity.changeset(episode_pop, episode_attrs), + on_conflict: :replace_all, + conflict_target: :rss_source_item + ) + + # Also increment podcast score + podcast_pop = + repo.get(PodcastPopularity, event.rss_source_feed) || + %PodcastPopularity{rss_source_feed: event.rss_source_feed} + + podcast_attrs = %{ + rss_source_feed: event.rss_source_feed, + score: podcast_pop.score + @score_like + } + + repo.insert_or_update( + PodcastPopularity.changeset(podcast_pop, podcast_attrs), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end + + defp apply_popularity_episode_unliked(event, repo) do + alias BaladosSyncProjections.Schemas.{EpisodePopularity, PodcastPopularity} + + # Episode popularity + case repo.get(EpisodePopularity, event.rss_source_item) do + nil -> + :ok + + episode_pop -> + attrs = %{ + score: max(episode_pop.score - @score_like, 0), + likes: max(episode_pop.likes - 1, 0), + likes_people: Enum.reject(episode_pop.likes_people || [], &(&1 == event.user_id)) + } + + {:ok, _} = + repo.insert_or_update( + EpisodePopularity.changeset(episode_pop, attrs), + on_conflict: :replace_all, + conflict_target: :rss_source_item + ) + end + + # Also decrement podcast score + case repo.get(PodcastPopularity, event.rss_source_feed) do + nil -> + {:ok, nil} + + podcast_pop -> + attrs = %{score: max(podcast_pop.score - @score_like, 0)} + + repo.insert_or_update( + PodcastPopularity.changeset(podcast_pop, attrs), + on_conflict: :replace_all, + conflict_target: :rss_source_feed + ) + end + end + + defp add_recent_user(people_list, user_id) when is_list(people_list) do + [user_id | Enum.reject(people_list, &(&1 == user_id))] + |> Enum.take(10) + end + + defp add_recent_user(_people_list, user_id), do: [user_id] + # ============================================================================ # DateTime Helpers # ============================================================================ From 9d746e034810b6fd1688f98d6c4362bbeaf3c95e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 17:19:23 +0100 Subject: [PATCH 5/9] fix: LWW conflict resolution for sync likes + add tests - Fix sync_likes LWW to compare max(liked_at, unliked_at) instead of just liked_at, preventing stale client likes from erasing server unlikes - Add max_datetime/2 helper for timestamp comparison - Add 5 sync_likes LWW tests: client wins, server wins, client unlike wins, server unlike preserved, new like creation Co-Authored-By: Claude Opus 4.6 --- .../controllers/sync_controller.ex | 13 +- .../controllers/sync_controller_test.exs | 170 +++++++++++++++++- 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index 93e8a424..2ae95629 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -437,9 +437,11 @@ defmodule BaladosSyncWeb.SyncController do |> repo.insert() record -> - # LWW: update if client is newer - client_ts = like.liked_at || now - server_ts = record.liked_at || ~U[1970-01-01 00:00:00Z] + # LWW: compare latest action timestamp (max of liked_at, unliked_at) + client_ts = max_datetime(like.liked_at, like.unliked_at) || now + + server_ts = + max_datetime(record.liked_at, record.unliked_at) || ~U[1970-01-01 00:00:00Z] if DateTime.compare(client_ts, server_ts) != :lt do record @@ -453,6 +455,11 @@ defmodule BaladosSyncWeb.SyncController do end) end + defp max_datetime(nil, nil), do: nil + defp max_datetime(a, nil), do: a + defp max_datetime(nil, b), do: b + defp max_datetime(a, b), do: if(DateTime.compare(a, b) != :lt, do: a, else: b) + defp get_server_subscriptions(user_id) do from(s in Subscription, where: s.user_id == ^user_id) |> ProjectionsRepo.all() diff --git a/apps/balados_sync_web/test/balados_sync_web/controllers/sync_controller_test.exs b/apps/balados_sync_web/test/balados_sync_web/controllers/sync_controller_test.exs index 6908c8ef..9ba4f964 100644 --- a/apps/balados_sync_web/test/balados_sync_web/controllers/sync_controller_test.exs +++ b/apps/balados_sync_web/test/balados_sync_web/controllers/sync_controller_test.exs @@ -2,9 +2,9 @@ defmodule BaladosSyncWeb.SyncControllerTest do use BaladosSyncWeb.ConnCase alias BaladosSyncCore.Dispatcher - alias BaladosSyncCore.Commands.{Subscribe, RecordPlay, CreatePlaylist} + alias BaladosSyncCore.Commands.{Subscribe, CreatePlaylist} alias BaladosSyncProjections.ProjectionsRepo - alias BaladosSyncProjections.Schemas.{Subscription, PlayStatus} + alias BaladosSyncProjections.Schemas.{Subscription, PlayStatus, UserLike} alias BaladosSyncWeb.JwtTestHelper @moduletag :sync_controller @@ -615,4 +615,170 @@ defmodule BaladosSyncWeb.SyncControllerTest do assert is_list(response["changes"]["playlists"]) end end + + describe "POST /api/v1/sync - like sync LWW" do + defp insert_like(user_id, feed, liked_at, unliked_at \\ nil) do + %UserLike{} + |> UserLike.changeset(%{ + user_id: user_id, + rss_source_feed: feed, + rss_source_item: nil, + liked_at: liked_at, + unliked_at: unliked_at + }) + |> ProjectionsRepo.insert!() + end + + test "client like newer than server wins", %{conn: conn, user_id: user_id} do + feed = "aHR0cHM6Ly9saWtlLWx3dy5leGFtcGxlLmNvbQ==" + old_time = ~U[2025-01-01 00:00:00Z] + new_time = ~U[2025-06-01 00:00:00Z] + + insert_like(user_id, feed, old_time) + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.sync"]) + |> post("/api/v1/sync", %{ + "changes" => %{ + "likes" => [ + %{ + "rss_source_feed" => feed, + "liked_at" => DateTime.to_iso8601(new_time) + } + ] + } + }) + + assert json_response(conn, 200)["sync_token"] + + # Verify server was updated + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.liked_at == new_time + end + + test "server like newer than client is preserved", %{conn: conn, user_id: user_id} do + feed = "aHR0cHM6Ly9saWtlLWx3dzIuZXhhbXBsZS5jb20=" + old_time = ~U[2025-01-01 00:00:00Z] + new_time = ~U[2025-06-01 00:00:00Z] + + insert_like(user_id, feed, new_time) + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.sync"]) + |> post("/api/v1/sync", %{ + "changes" => %{ + "likes" => [ + %{ + "rss_source_feed" => feed, + "liked_at" => DateTime.to_iso8601(old_time) + } + ] + } + }) + + assert json_response(conn, 200)["sync_token"] + + # Server data should be preserved + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.liked_at == new_time + end + + test "client unlike with newer timestamp wins over server like", %{ + conn: conn, + user_id: user_id + } do + feed = "aHR0cHM6Ly9saWtlLWx3dzMuZXhhbXBsZS5jb20=" + liked_time = ~U[2025-01-01 00:00:00Z] + unlike_time = ~U[2025-06-01 00:00:00Z] + + # Server has an active like + insert_like(user_id, feed, liked_time) + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.sync"]) + |> post("/api/v1/sync", %{ + "changes" => %{ + "likes" => [ + %{ + "rss_source_feed" => feed, + "liked_at" => DateTime.to_iso8601(liked_time), + "unliked_at" => DateTime.to_iso8601(unlike_time) + } + ] + } + }) + + assert json_response(conn, 200)["sync_token"] + + # Verify unlike was applied + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.unliked_at == unlike_time + end + + test "server unlike is preserved when client sends stale like", %{ + conn: conn, + user_id: user_id + } do + feed = "aHR0cHM6Ly9saWtlLWx3dzQuZXhhbXBsZS5jb20=" + liked_time = ~U[2025-01-01 00:00:00Z] + unlike_time = ~U[2025-06-01 00:00:00Z] + + # Server has an unliked record (liked at T1, unliked at T2) + insert_like(user_id, feed, liked_time, unlike_time) + + # Client sends stale like (only knows about T1 like, missed the T2 unlike) + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.sync"]) + |> post("/api/v1/sync", %{ + "changes" => %{ + "likes" => [ + %{ + "rss_source_feed" => feed, + "liked_at" => DateTime.to_iso8601(liked_time) + } + ] + } + }) + + assert json_response(conn, 200)["sync_token"] + + # Server unlike should be preserved (T2 > T1) + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like.unliked_at == unlike_time + end + + test "new client like creates record when none exists on server", %{ + conn: conn, + user_id: user_id + } do + feed = "aHR0cHM6Ly9saWtlLWx3dzUuZXhhbXBsZS5jb20=" + liked_time = ~U[2025-06-01 00:00:00Z] + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.sync"]) + |> post("/api/v1/sync", %{ + "changes" => %{ + "likes" => [ + %{ + "rss_source_feed" => feed, + "liked_at" => DateTime.to_iso8601(liked_time) + } + ] + } + }) + + assert json_response(conn, 200)["sync_token"] + + # Verify like was created + like = ProjectionsRepo.get_by(UserLike, user_id: user_id, rss_source_feed: feed) + assert like != nil + assert like.liked_at == liked_time + assert like.unliked_at == nil + end + end end From f0e58390087f0a4d6d161d66b0112aa5e6c4b836 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 17:37:38 +0100 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20address=20review=20round=205=20?= =?UTF-8?q?=E2=80=94=20deduplicate=20sync=20likes,=20document=20assumption?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deduplicate likes by (feed, item) in parse_likes to prevent Ecto.Multi key collision on duplicate sync payload entries - Document privacy simplification in popularity test helpers - Add comment explaining why episode likes bump podcast score Co-Authored-By: Claude Opus 4.6 --- .../projectors/popularity_projector.ex | 2 +- .../test/support/projector_test_case.ex | 4 ++++ .../lib/balados_sync_web/controllers/sync_controller.ex | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex index 053a3b32..58aa47a4 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex @@ -474,7 +474,7 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do ) end) - # Also increment podcast score for episode likes + # Episode engagement signals the parent podcast's quality, so also bump podcast score Ecto.Multi.run(multi, :podcast_score_from_episode_like, fn repo, _changes -> popularity = repo.get(PodcastPopularity, event.rss_source_feed) || diff --git a/apps/balados_sync_projections/test/support/projector_test_case.ex b/apps/balados_sync_projections/test/support/projector_test_case.ex index 60229eac..3b7a8f62 100644 --- a/apps/balados_sync_projections/test/support/projector_test_case.ex +++ b/apps/balados_sync_projections/test/support/projector_test_case.ex @@ -749,6 +749,10 @@ defmodule BaladosSyncProjections.ProjectorTestCase do # ============================================================================ # Popularity Projector Logic (Like events) + # + # Note: these test helpers assume all users are public (always add to likes_people). + # The real PopularityProjector checks UserPrivacy and conditionally adds users. + # Privacy-related behaviour is tested separately via the full projector integration. # ============================================================================ @score_like 7 diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index 2ae95629..870ba1fc 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -700,6 +700,7 @@ defmodule BaladosSyncWeb.SyncController do } end) |> Enum.filter(fn like -> is_binary(like.rss_source_feed) end) + |> Enum.uniq_by(fn like -> {like.rss_source_feed, like.rss_source_item} end) end defp parse_likes(_), do: [] From affee1c25e4a9756fd811bfb32fcb84158499969 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 17:51:58 +0100 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20address=20review=20round=206=20?= =?UTF-8?q?=E2=80=94=20delete=5Fall=20pattern=20match,=20active=20likes=20?= =?UTF-8?q?index,=20scope=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pattern match `delete_all` result in LikeCheckpoint handler to avoid discarded result compiler warning - Add partial index on user_likes WHERE unliked_at IS NULL for active likes queries (GET /api/v1/likes) - Extend sync bypass comment to document SnapshotLike implication - Add test for `user.likes` parent scope in like_controller_test Co-Authored-By: Claude Opus 4.6 --- .../projectors/like_projector.ex | 5 +++-- .../migrations/20260219120000_create_user_likes.exs | 7 +++++++ .../balados_sync_web/controllers/sync_controller.ex | 5 +++++ .../controllers/like_controller_test.exs | 10 ++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex index 4e69cc8c..17ce0d07 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex @@ -69,8 +69,9 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do Ecto.Multi.run(multi, :checkpoint, fn repo, _changes -> # Delete all existing likes for this user - from(ul in UserLike, where: ul.user_id == ^event.user_id) - |> repo.delete_all() + {_count, _} = + from(ul in UserLike, where: ul.user_id == ^event.user_id) + |> repo.delete_all() # Normalize keys: after JSON deserialization, nested map keys may be strings podcast_likes = LikeNormalizer.normalize(event.podcast_likes) diff --git a/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs b/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs index 04f8fb6c..c5319d1f 100644 --- a/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs +++ b/apps/balados_sync_projections/priv/projections_repo/migrations/20260219120000_create_user_likes.exs @@ -29,6 +29,13 @@ defmodule BaladosSyncProjections.ProjectionsRepo.Migrations.CreateUserLikes do create index(:user_likes, [:user_id], prefix: "users") create index(:user_likes, [:rss_source_feed], prefix: "users") + + # Partial index for active likes queries (GET /api/v1/likes filters on unliked_at IS NULL) + create index(:user_likes, [:user_id], + prefix: "users", + where: "unliked_at IS NULL", + name: "user_likes_user_active" + ) end def down do diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index 870ba1fc..ab489c04 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -397,6 +397,11 @@ defmodule BaladosSyncWeb.SyncController do # aggregate will re-emit a PodcastLiked event (projection no-ops via upsert, # but the event store accumulates a duplicate event). Low impact since likes # are lightweight events. Matches established pattern for subscriptions/plays. + # + # Additionally, SnapshotLike checkpoints are built from aggregate state only, + # so synced-only likes won't appear in snapshots. This is acceptable because + # snapshots serve to compact the event stream, and synced likes are already + # persisted in the projections table independently of the aggregate. defp sync_likes(multi, _user_id, likes, _now) when likes == [], do: multi defp sync_likes(multi, user_id, likes, now) do diff --git a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs index 4ef34993..f3b14cd0 100644 --- a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs +++ b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs @@ -135,5 +135,15 @@ defmodule BaladosSyncWeb.LikeControllerTest do response = json_response(conn, 200) assert response["likes"] == [] end + + test "works with parent scope user.likes", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes"]) + |> get("/api/v1/likes") + + response = json_response(conn, 200) + assert response["likes"] == [] + end end end From cf605a8e98aaa3fea6783a9a8f1b2ed82a272947 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 18:28:39 +0100 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20remaining=20review=20items?= =?UTF-8?q?=20=E2=80=94=20episode=20unlike=20test,=20ordering,=20validatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing test for DELETE /api/v1/likes/:feed/:item (unlike episode) - Add TODO comment in PopularityProjector about LikeCheckpoint gap (see #258) - Add order_by [desc: liked_at] to LikeController.index for stable response - Reject empty string rss_source_feed in parse_likes - Created follow-up issue #259 for sync bypass bidirectional CQRS Co-Authored-By: Claude Opus 4.6 --- .../projectors/popularity_projector.ex | 7 +++++++ .../controllers/like_controller.ex | 1 + .../controllers/sync_controller.ex | 2 +- .../controllers/like_controller_test.exs | 17 +++++++++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex index 58aa47a4..8971d3f5 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/popularity_projector.ex @@ -530,6 +530,13 @@ defmodule BaladosSyncProjections.Projectors.PopularityProjector do end) end) + # TODO: LikeCheckpoint is not handled here. If the event store is ever partially + # replayed starting from a LikeCheckpoint (e.g. stream compaction), this projector + # won't see the original PodcastLiked/EpisodeLiked events and counts will be + # incorrect. For now this is low-risk (no stream compaction exists). If compaction + # is added, consider re-deriving popularity counts from the UserLike projection + # table when a LikeCheckpoint is encountered. See issue #258. + project(%PopularityRecalculated{} = event, _metadata, fn multi -> # Event émis par le worker après recalcul depuis public_events if event.rss_source_item do diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex index 622594a0..f5db8206 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex @@ -138,6 +138,7 @@ defmodule BaladosSyncWeb.LikeController do likes = from(ul in UserLike, where: ul.user_id == ^user_id and is_nil(ul.unliked_at), + order_by: [desc: ul.liked_at], select: %{ rss_source_feed: ul.rss_source_feed, rss_source_item: ul.rss_source_item, diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex index ab489c04..2ee60d54 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/sync_controller.ex @@ -704,7 +704,7 @@ defmodule BaladosSyncWeb.SyncController do unliked_at: parse_datetime(like["unliked_at"]) } end) - |> Enum.filter(fn like -> is_binary(like.rss_source_feed) end) + |> Enum.filter(fn like -> is_binary(like.rss_source_feed) and like.rss_source_feed != "" end) |> Enum.uniq_by(fn like -> {like.rss_source_feed, like.rss_source_item} end) end diff --git a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs index f3b14cd0..a59e238e 100644 --- a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs +++ b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs @@ -88,6 +88,23 @@ defmodule BaladosSyncWeb.LikeControllerTest do end end + describe "DELETE /api/v1/likes/:feed/:item - unlike episode" do + test "returns 401 without authorization", %{conn: conn} do + conn = delete(conn, "/api/v1/likes/dGVzdC1mZWVk/dGVzdC1pdGVt") + assert json_response(conn, 401)["error"] == "UNAUTHORIZED" + end + + test "successfully unlikes an episode", %{conn: conn, user_id: user_id} do + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.write"]) + |> delete("/api/v1/likes/dGVzdC1mZWVk/dGVzdC1pdGVt") + + response = json_response(conn, 200) + assert response["status"] == "success" + end + end + describe "GET /api/v1/likes - list likes" do test "returns 401 without authorization", %{conn: conn} do conn = get(conn, "/api/v1/likes") From 869c9d76fc4dd8936ad6ab69b148fdbe0e641937 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 18:59:19 +0100 Subject: [PATCH 9/9] fix: pagination, URL-safe base64 docs, insert_all safety, follow-ups - Add pagination to GET /api/v1/likes (limit/offset, max 500, has_more) - Document URL-safe base64 requirement for path params in LikeController - Pattern match insert_all result in LikeProjector checkpoint handler - Add pagination test for limit parameter - Created follow-up issues #259 (sync CQRS bypass) and #260 (private user popularity tests) Co-Authored-By: Claude Opus 4.6 --- .../projectors/like_projector.ex | 12 ++++--- .../controllers/like_controller.ex | 36 +++++++++++++++++-- .../controllers/like_controller_test.exs | 27 ++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex index 17ce0d07..938c73c2 100644 --- a/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex +++ b/apps/balados_sync_projections/lib/balados_sync_projections/projectors/like_projector.ex @@ -109,12 +109,14 @@ defmodule BaladosSyncProjections.Projectors.LikeProjector do entries = podcast_entries ++ episode_entries if entries != [] do - repo.insert_all(UserLike, entries) - end + {count, _} = repo.insert_all(UserLike, entries) - Logger.debug( - "[LikeProjector] Checkpoint applied: #{length(entries)} likes for user=#{event.user_id}" - ) + Logger.debug( + "[LikeProjector] Checkpoint applied: #{count} likes for user=#{event.user_id}" + ) + else + Logger.debug("[LikeProjector] Checkpoint applied: 0 likes for user=#{event.user_id}") + end {:ok, :checkpoint_applied} end) diff --git a/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex b/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex index f5db8206..08d72a16 100644 --- a/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex +++ b/apps/balados_sync_web/lib/balados_sync_web/controllers/like_controller.ex @@ -7,7 +7,14 @@ defmodule BaladosSyncWeb.LikeController do - `POST /api/v1/likes` - Like a podcast or episode - `DELETE /api/v1/likes/:feed` - Unlike a podcast - `DELETE /api/v1/likes/:feed/:item` - Unlike an episode - - `GET /api/v1/likes` - List all active likes for the user + - `GET /api/v1/likes` - List all active likes for the user (paginated) + + ## Path Parameters + + The `:feed` and `:item` path parameters must be **URL-safe base64** encoded + (RFC 4648 §5: `-` instead of `+`, `_` instead of `/`, no `=` padding). + Standard base64 contains `/` which would break path routing. + See `balados.app/src/utils/rssEncoding.ts` for the shared encoding functions. """ use BaladosSyncWeb, :controller @@ -132,13 +139,19 @@ defmodule BaladosSyncWeb.LikeController do end end - def index(conn, _params) do + @max_likes 500 + + def index(conn, params) do user_id = conn.assigns.current_user_id + limit = min(parse_int(params["limit"], @max_likes), @max_likes) + offset = parse_int(params["offset"], 0) likes = from(ul in UserLike, where: ul.user_id == ^user_id and is_nil(ul.unliked_at), order_by: [desc: ul.liked_at], + limit: ^(limit + 1), + offset: ^offset, select: %{ rss_source_feed: ul.rss_source_feed, rss_source_item: ul.rss_source_item, @@ -147,6 +160,23 @@ defmodule BaladosSyncWeb.LikeController do ) |> ProjectionsRepo.all() - json(conn, %{likes: likes}) + has_more = length(likes) > limit + + json(conn, %{ + likes: Enum.take(likes, limit), + has_more: has_more + }) end + + defp parse_int(nil, default), do: default + + defp parse_int(val, default) when is_binary(val) do + case Integer.parse(val) do + {n, _} when n >= 0 -> n + _ -> default + end + end + + defp parse_int(val, _default) when is_integer(val) and val >= 0, do: val + defp parse_int(_, default), do: default end diff --git a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs index a59e238e..7925f19c 100644 --- a/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs +++ b/apps/balados_sync_web/test/balados_sync_web/controllers/like_controller_test.exs @@ -119,6 +119,7 @@ defmodule BaladosSyncWeb.LikeControllerTest do response = json_response(conn, 200) assert response["likes"] == [] + assert response["has_more"] == false end test "returns likes after liking a podcast", %{conn: conn, user_id: user_id} do @@ -141,6 +142,30 @@ defmodule BaladosSyncWeb.LikeControllerTest do response = json_response(conn, 200) assert length(response["likes"]) == 1 assert hd(response["likes"])["rss_source_feed"] == "dGVzdC1mZWVk" + assert response["has_more"] == false + end + + test "respects limit parameter", %{conn: conn, user_id: user_id} do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + for i <- 1..3 do + ProjectionsRepo.insert!(%UserLike{ + user_id: user_id, + rss_source_feed: "feed-#{i}", + rss_source_item: nil, + liked_at: DateTime.add(now, i, :second), + unliked_at: nil + }) + end + + conn = + conn + |> JwtTestHelper.authenticate_conn(user_id, scopes: ["user.likes.read"]) + |> get("/api/v1/likes?limit=2") + + response = json_response(conn, 200) + assert length(response["likes"]) == 2 + assert response["has_more"] == true end test "works with wildcard scope", %{conn: conn, user_id: user_id} do @@ -151,6 +176,7 @@ defmodule BaladosSyncWeb.LikeControllerTest do response = json_response(conn, 200) assert response["likes"] == [] + assert response["has_more"] == false end test "works with parent scope user.likes", %{conn: conn, user_id: user_id} do @@ -161,6 +187,7 @@ defmodule BaladosSyncWeb.LikeControllerTest do response = json_response(conn, 200) assert response["likes"] == [] + assert response["has_more"] == false end end end