feat: add user data export and sofa export re-import support

- Add `GET /api/export/user-data` route that streams a JSON attachment of the authenticated user's full library data, named `sofa-export-<name>-<date>.json`
- Add `generateUserExport` in `@sofa/core/export` and a matching `parseSofaExport` parser so exported files can be re-imported via the existing import pipeline
- Register `sofa` as a new `ImportSource` in the API contract/schemas and wire up `parseSofaExport` in the `parseFile` procedure
- Add `EXPORT_FAILED` error code to the API error registry and surface it in web and native error-message maps
- Update the account settings section with export/import UI (download button, import progress)
- Add tests for `generateUserExport`, `parseSofaExport`, and round-trip fidelity
This commit is contained in:
2026-03-21 17:12:15 -04:00
parent 58cf2e689f
commit 61013ee41f
23 changed files with 1214 additions and 39 deletions
+146
View File
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, test } from "bun:test";
import {
clearAllTables,
insertEpisodeWatch,
insertMovieWatch,
insertRating,
insertStatus,
insertTitle,
insertTvShow,
insertUser,
} from "@sofa/db/test-utils";
import { generateUserExport } from "../src/export";
beforeEach(() => clearAllTables());
describe("generateUserExport", () => {
test("exports empty data for user with no tracking", () => {
insertUser("user-1");
const result = generateUserExport("user-1", { name: "Test", email: "test@example.com" });
expect(result.version).toBe(1);
expect(result.user).toEqual({ name: "Test", email: "test@example.com" });
expect(result.library).toHaveLength(0);
expect(result.movieWatches).toHaveLength(0);
expect(result.episodeWatches).toHaveLength(0);
expect(result.ratings).toHaveLength(0);
expect(result.exportedAt).toBeTruthy();
});
test("exports library statuses", () => {
insertUser("user-1");
insertTitle({
id: "t1",
tmdbId: 550,
type: "movie",
title: "Fight Club",
releaseDate: "1999-10-15",
});
insertTitle({ id: "t2", tmdbId: 1396, type: "tv", title: "Breaking Bad" });
insertTitle({ id: "t3", tmdbId: 100, type: "movie", title: "Watchlisted" });
insertStatus("user-1", "t1", "completed");
insertStatus("user-1", "t2", "in_progress");
insertStatus("user-1", "t3", "watchlist");
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
expect(result.library).toHaveLength(3);
const fightClub = result.library.find((l) => l.tmdbId === 550);
expect(fightClub?.status).toBe("completed");
expect(fightClub?.type).toBe("movie");
expect(fightClub?.title).toBe("Fight Club");
expect(fightClub?.year).toBe(1999);
const bb = result.library.find((l) => l.tmdbId === 1396);
expect(bb?.status).toBe("in_progress");
expect(bb?.type).toBe("tv");
});
test("exports movie watches", () => {
insertUser("user-1");
insertTitle({
id: "t1",
tmdbId: 550,
type: "movie",
title: "Fight Club",
releaseDate: "1999-10-15",
});
const watchedAt = new Date("2025-06-15T20:00:00.000Z");
insertMovieWatch("user-1", "t1", watchedAt);
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
expect(result.movieWatches).toHaveLength(1);
expect(result.movieWatches[0].tmdbId).toBe(550);
expect(result.movieWatches[0].title).toBe("Fight Club");
expect(result.movieWatches[0].year).toBe(1999);
expect(result.movieWatches[0].watchedAt).toBe(watchedAt.toISOString());
});
test("exports episode watches with show context", () => {
insertUser("user-1");
const { episodeIds } = insertTvShow("tv-1", 1396, 1, 2, { title: "Breaking Bad" });
const watchedAt = new Date("2025-02-01T20:00:00.000Z");
insertEpisodeWatch("user-1", episodeIds[0], watchedAt);
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
expect(result.episodeWatches).toHaveLength(1);
expect(result.episodeWatches[0].showTmdbId).toBe(1396);
expect(result.episodeWatches[0].showTitle).toBe("Breaking Bad");
expect(result.episodeWatches[0].seasonNumber).toBe(1);
expect(result.episodeWatches[0].episodeNumber).toBe(1);
expect(result.episodeWatches[0].episodeName).toBe("S1E1");
expect(result.episodeWatches[0].watchedAt).toBe(watchedAt.toISOString());
});
test("exports ratings", () => {
insertUser("user-1");
insertTitle({
id: "t1",
tmdbId: 550,
type: "movie",
title: "Fight Club",
releaseDate: "1999-10-15",
});
insertRating("user-1", "t1", 5);
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
expect(result.ratings).toHaveLength(1);
expect(result.ratings[0].tmdbId).toBe(550);
expect(result.ratings[0].type).toBe("movie");
expect(result.ratings[0].rating).toBe(5);
});
test("does not include other users data", () => {
insertUser("user-1");
insertUser("user-2");
insertTitle({ id: "t1", tmdbId: 550, type: "movie", title: "Fight Club" });
insertTitle({ id: "t2", tmdbId: 100, type: "movie", title: "Other Movie" });
insertStatus("user-1", "t1", "completed");
insertMovieWatch("user-1", "t1");
insertRating("user-1", "t1", 5);
insertStatus("user-2", "t2", "watchlist");
insertMovieWatch("user-2", "t2");
insertRating("user-2", "t2", 3);
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
expect(result.library).toHaveLength(1);
expect(result.library[0].tmdbId).toBe(550);
expect(result.movieWatches).toHaveLength(1);
expect(result.movieWatches[0].tmdbId).toBe(550);
expect(result.ratings).toHaveLength(1);
expect(result.ratings[0].tmdbId).toBe(550);
});
});