Files
sofa/packages/core/test/discovery.test.ts
T
4fc6b2f8d1 feat: add filtering and sorting across library, explore, and upcoming (#21)
* feat: add filtering and sorting across library, explore, and upcoming

Add comprehensive filtering and sorting to all list views on both web and mobile.

Library (new dedicated page):
- New /library route (web) with URL search param persistence
- New Library tab (5th tab, mobile) with FlashList grid
- Filters: status (multi-select), type, genre (library-only), user rating range,
  release year (decade presets + custom), content rating, available to stream
- Sort by: title, date added, release date, popularity, user rating, TMDB rating
- Text search within collection
- Filter popover (web) / bottom sheet with Apply (mobile)
- Remove old dashboard.library endpoint in favor of library.list

Explore/Discover:
- New Discover section on Explore page with inline filter controls
- TMDB filters: genre (now optional), year range, min rating, sort order,
  original language, streaming provider
- Watch provider list endpoint (WATCH_REGION env var, default "US")
- Default to popular content when no filters selected

Upcoming:
- Type filter (All/Movies/TV Shows) and status filter (All/Watching/Watchlist)
- Toggle chips on both web (URL params) and mobile
- Backend: mediaType and statusFilter params on upcoming endpoint

Navigation:
- Library added to web nav bar (desktop + mobile tab bar)
- Library added as 5th tab on mobile (between Home and Explore)
- Dashboard library section simplified to compact preview with "See all" link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add user streaming platform subscriptions and redesign filter UI

Replace the denormalized availabilityOffers table with a normalized
platforms + titleAvailability + userPlatforms schema. Users can now
declare which streaming services they subscribe to, enabling
personalized "On your services" availability display and filtered
library/explore results.

Backend: new platforms table seeded from TMDB provider data on startup,
new API endpoints (platforms.list, account.platforms, account.updatePlatforms),
title detail annotates availability with isUserSubscribed flag, library
"on my services" filter checks user's subscribed platforms.

Web UI: post-registration onboarding page for platform selection,
streaming services settings section, title detail split into "On your
services" / "Also available on", explore dropdown uses platforms table
with active filter highlighting. Library filters redesigned from
cluttered popover to clean collapsible inline strip with consolidated
dropdowns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address race conditions, stale state, and edge cases in platforms feature

- Fix debounced autosave race in streaming services settings: use ref
  for latest selection + counter guard so only the most recent save
  updates UI. Clean up timers on unmount.
- Fix explore provider dropdown desyncing from query filter when
  switching Movie/TV type: reset selectedPlatformId alongside providerId.
- Fix platformIdsExist rejecting duplicate valid IDs by deduplicating
  input before comparing against DB results.
- Fix stale platform metadata: always upsert name/logo on TMDB refresh
  instead of skipping when platform already exists.
- Fix invisible ratingMax filter: clear ratingMax when user changes
  ratingMin since the UI only exposes a single rating dropdown now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clear stale saved-state timer and preserve falsy filter values

- Clear previous savedTimerRef timeout before scheduling a new one in
  streaming-services-section, preventing an older timer from hiding the
  "Saved" indicator too early on rapid successive saves
- Replace `value || undefined` with explicit empty-value check in
  library filter handler so numeric 0 (e.g. ratingMin=0) is not
  incorrectly dropped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TanStack Form and migrate auth + change password forms

Set up TanStack Form v1 composition layer with createFormHook, pre-bound
field components (TextField, TextareaField, CheckboxField, SwitchField,
SubmitButton), and migrate the two traditional submit-based forms:

- AuthForm: replace 4 useState calls with useAppForm, use form.Field
  for each input while preserving motion animation layout
- ChangePasswordDialog: replace 6 useState calls with useAppForm + Zod
  schema validation (cross-field password match, min length), per-field
  error display, form.reset() on dialog close

Also adds @tanstack/react-form to the monorepo catalog and updates both
web and native apps to reference it via catalog:.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:07:14 -04:00

378 lines
12 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import {
clearAllTables,
insertPlatform,
insertTitleAvailability,
insertEpisodeWatch,
insertMovieWatch,
insertRating,
insertRecommendation,
insertStatus,
insertTitle,
insertTvShow,
insertUser,
} from "@sofa/test/db";
import {
getContinueWatchingFeed,
getNewAvailableFeed,
getRecommendationsFeed,
getRecommendationsForTitle,
getUserStats,
getWatchCount,
getWatchHistory,
} from "../src/discovery";
const TEST_NOW = new Date("2026-03-01T12:00:00Z");
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(TEST_NOW);
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
clearAllTables();
});
// ── getWatchCount ───────────────────────────────────────────────────
describe("getWatchCount", () => {
test("counts movie watches within period", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertMovieWatch("user-1", "m1");
insertMovieWatch("user-1", "m2");
const count = getWatchCount("user-1", "movies", "this_month");
expect(count).toBe(2);
});
test("counts episode watches within period", () => {
insertUser();
const { episodeIds } = insertTvShow();
insertEpisodeWatch("user-1", episodeIds[0]);
insertEpisodeWatch("user-1", episodeIds[1]);
const count = getWatchCount("user-1", "episodes", "this_week");
expect(count).toBe(2);
});
test("excludes watches outside period", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
// Watch from 2 years ago
const oldDate = new Date(TEST_NOW);
oldDate.setFullYear(oldDate.getFullYear() - 2);
insertMovieWatch("user-1", "m1", oldDate);
const count = getWatchCount("user-1", "movies", "this_year");
expect(count).toBe(0);
});
test("returns 0 when no watches exist", () => {
insertUser();
const count = getWatchCount("user-1", "movies", "today");
expect(count).toBe(0);
});
});
// ── getWatchHistory ─────────────────────────────────────────────────
describe("getWatchHistory", () => {
test("returns bucketed history with correct total count", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertMovieWatch("user-1", "m1");
insertMovieWatch("user-1", "m2");
const history = getWatchHistory("user-1", "movies", "this_week");
expect(history).toHaveLength(7);
const totalCount = history.reduce((sum, b) => sum + b.count, 0);
expect(totalCount).toBe(2);
});
test("returns all-zero buckets when no watches", () => {
insertUser();
const history = getWatchHistory("user-1", "movies", "this_month");
expect(history).toHaveLength(30);
expect(history.every((b) => b.count === 0)).toBe(true);
});
test("returns correct bucket count for today period", () => {
insertUser();
const history = getWatchHistory("user-1", "episodes", "today");
expect(history).toHaveLength(24);
});
test("returns correct bucket count for this_year period", () => {
insertUser();
const history = getWatchHistory("user-1", "movies", "this_year");
expect(history).toHaveLength(12);
});
});
// ── getUserStats ────────────────────────────────────────────────────
describe("getUserStats", () => {
test("returns correct stats", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
insertMovieWatch("user-1", "m1");
insertMovieWatch("user-1", "m2");
insertEpisodeWatch("user-1", "tv-1-s1e1");
insertStatus("user-1", "m1", "completed");
insertStatus("user-1", "m2", "completed");
insertStatus("user-1", titleId, "in_progress");
const stats = getUserStats("user-1");
expect(stats.moviesThisMonth).toBe(2);
expect(stats.episodesThisWeek).toBe(1);
expect(stats.librarySize).toBe(3);
expect(stats.completed).toBe(2);
});
test("returns zeros when no data", () => {
insertUser();
const stats = getUserStats("user-1");
expect(stats.moviesThisMonth).toBe(0);
expect(stats.episodesThisWeek).toBe(0);
expect(stats.librarySize).toBe(0);
expect(stats.completed).toBe(0);
});
});
// ── getContinueWatchingFeed ─────────────────────────────────────────
describe("getContinueWatchingFeed", () => {
test("returns in-progress shows with next unwatched episode", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
insertStatus("user-1", titleId, "in_progress");
insertEpisodeWatch("user-1", episodeIds[0]);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0].title.id).toBe(titleId);
expect(feed[0].nextEpisode?.episodeNumber).toBe(2);
expect(feed[0].watchedEpisodes).toBe(1);
expect(feed[0].totalEpisodes).toBe(3);
});
test("excludes completed shows", () => {
insertUser();
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
insertStatus("user-1", titleId, "completed");
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("excludes movies", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1, type: "movie" });
insertStatus("user-1", "m1", "in_progress");
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("returns empty when no in-progress shows", () => {
insertUser();
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("sorts by most recent watch", () => {
insertUser();
const show1 = insertTvShow("tv-1", 11111, 1, 3);
const show2 = insertTvShow("tv-2", 22222, 1, 3);
insertStatus("user-1", show1.titleId, "in_progress");
insertStatus("user-1", show2.titleId, "in_progress");
const older = new Date("2026-01-01");
const newer = new Date("2026-03-01");
insertEpisodeWatch("user-1", show1.episodeIds[0], older);
insertEpisodeWatch("user-1", show2.episodeIds[0], newer);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(2);
expect(feed[0].title.id).toBe("tv-2");
expect(feed[1].title.id).toBe("tv-1");
});
test("skips show when all episodes are watched (no next episode)", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 2);
insertStatus("user-1", titleId, "in_progress");
for (const epId of episodeIds) {
insertEpisodeWatch("user-1", epId);
}
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
});
// ── getNewAvailableFeed ─────────────────────────────────────────────
describe("getNewAvailableFeed", () => {
test("returns titles with availability offers", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertStatus("user-1", "m1", "watchlist");
const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
insertTitleAvailability("m1", pId);
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0].titleId).toBe("m1");
});
test("excludes titles without availability", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertStatus("user-1", "m1", "watchlist");
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(0);
});
test("excludes titles not in user library", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
insertTitleAvailability("m1", pId);
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(0);
});
});
// ── getRecommendationsFeed ──────────────────────────────────────────
describe("getRecommendationsFeed", () => {
test("returns recommendations from completed titles", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1, title: "Source Movie" });
insertTitle({ id: "m2", tmdbId: 2, title: "Recommended Movie" });
insertStatus("user-1", "m1", "completed");
insertRecommendation("m1", "m2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0]?.id).toBe("m2");
});
test("returns recommendations from highly-rated titles", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertStatus("user-1", "m1", "watchlist");
insertRating("user-1", "m1", 5);
insertRecommendation("m1", "m2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(1);
});
test("excludes already-tracked titles", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertStatus("user-1", "m1", "completed");
insertStatus("user-1", "m2", "watchlist");
insertRecommendation("m1", "m2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(0);
});
test("returns empty when no source titles", () => {
insertUser();
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(0);
});
test("scores higher when recommended by multiple sources", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertTitle({ id: "m3", tmdbId: 3 });
insertTitle({ id: "rec1", tmdbId: 10, title: "Double Recommended" });
insertTitle({ id: "rec2", tmdbId: 20, title: "Single Recommended" });
insertStatus("user-1", "m1", "completed");
insertStatus("user-1", "m2", "completed");
// rec1 recommended by both m1 and m2
insertRecommendation("m1", "rec1", { rank: 1 });
insertRecommendation("m2", "rec1", { rank: 1 });
// rec2 recommended only by m1
insertRecommendation("m1", "rec2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(2);
expect(feed[0]?.id).toBe("rec1");
});
});
// ── getRecommendationsForTitle ──────────────────────────────────────
describe("getRecommendationsForTitle", () => {
test("returns ordered recommendations for a title", () => {
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "rec1", tmdbId: 10, title: "Rec One" });
insertTitle({ id: "rec2", tmdbId: 20, title: "Rec Two" });
insertRecommendation("m1", "rec1", { rank: 2 });
insertRecommendation("m1", "rec2", { rank: 1 });
const recs = getRecommendationsForTitle("m1");
expect(recs).toHaveLength(2);
expect(recs[0].title).toBe("Rec Two");
expect(recs[1].title).toBe("Rec One");
});
test("returns empty for unknown title", () => {
const recs = getRecommendationsForTitle("nonexistent");
expect(recs).toHaveLength(0);
});
test("returns empty when no recommendations exist", () => {
insertTitle({ id: "m1", tmdbId: 1 });
const recs = getRecommendationsForTitle("m1");
expect(recs).toHaveLength(0);
});
test("deduplicates titles returned by multiple recommendation sources", () => {
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "rec1", tmdbId: 10, title: "Rec One" });
insertTitle({ id: "rec2", tmdbId: 20, title: "Rec Two" });
insertRecommendation("m1", "rec1", {
source: "tmdb_similar",
rank: 1,
});
insertRecommendation("m1", "rec1", {
source: "tmdb_recommendations",
rank: 2,
});
insertRecommendation("m1", "rec2", {
source: "tmdb_recommendations",
rank: 3,
});
const recs = getRecommendationsForTitle("m1");
expect(recs).toHaveLength(2);
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
});
});