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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user