feat(page-tags): normalize page_tags onto the shared tags table

page_tags previously stored the tag inline as free-form text — the lone
un-normalized tag concept, while authors/genres/manga-tags all went
through a lookup table + FK. Fold page tags into the SAME shared `tags`
table that manga_tags uses: `page_tags.tag` becomes `page_tags.tag_id`
referencing `tags(id)`. Manga tags and page tags now share one global
vocabulary.

The HTTP contract is unchanged — the API still speaks tag *names*; the
repo resolves name<->id via `repo::tag::upsert_by_name` (the manga-tag
helper) and keeps the stricter page-tag `normalize_tag` at the boundary.
Frontend, DTOs, and response shapes are untouched. User-visible effect:
page-tag names now appear in the manga-tag autocomplete and vice versa.

Migration 0024 backfills `tags` from existing inline values (deduping
case-insensitively), repoints rows, swaps the unique/secondary indexes
to tag_id, and drops the inline column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 17:44:38 +02:00
parent 33f684887d
commit f30600162e
7 changed files with 182 additions and 41 deletions

View File

@@ -418,6 +418,72 @@ async fn tags_are_per_user_only(pool: PgPool) {
assert_eq!(body["tags"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn page_tag_and_manga_tag_share_one_tags_row(pool: PgPool) {
// The point of the normalization refactor: page tags now reference
// the shared `tags` table. A manga tag "Funny" and a page tag
// "funny" (case-folded) must resolve to a single `tags` row, and
// `page_tags.tag_id` must point at the same row as `manga_tags`.
let db = pool.clone();
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/tags"),
json!({ "name": "Funny" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
let (tag_count,): (i64,) =
sqlx::query_as("SELECT count(*) FROM tags WHERE lower(name) = 'funny'")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(tag_count, 1, "page tag and manga tag must share one tags row");
let (shared,): (bool,) = sqlx::query_as(
"SELECT (SELECT tag_id FROM page_tags LIMIT 1) \
= (SELECT tag_id FROM manga_tags LIMIT 1)",
)
.fetch_one(&db)
.await
.unwrap();
assert!(
shared,
"page_tags.tag_id must reference the same row as manga_tags.tag_id"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn adding_a_page_tag_creates_a_shared_tags_row(pool: PgPool) {
// Even with no manga tag in play, adding a page tag must populate
// the shared `tags` lookup table (one row per distinct name).
let db = pool.clone();
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
let (name,): (String,) =
sqlx::query_as("SELECT name FROM tags WHERE lower(name) = 'funny'")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(name, "funny");
}
#[sqlx::test(migrations = "./migrations")]
async fn tags_require_authentication(pool: PgPool) {
let h = common::harness(pool);