Files
minstrel/internal/api/library_sync_views_test.go
T
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00

102 lines
2.9 KiB
Go

package api
import (
"encoding/json"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// These tests pin the wire-format keys for /api/library/sync upserts.
// Without them, sqlc model field-name drift or accidental
// `json.Marshal(rawStruct)` regressions would silently break the
// Flutter client (which reads snake_case keys via _*FromJson helpers
// in flutter_client/lib/cache/sync_controller.dart).
// validUUID is a deterministic test UUID — not a real value, just
// something that pgtype.UUID.Valid will accept.
var validUUID = pgtype.UUID{
Bytes: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
Valid: true,
}
func assertJSONKeys(t *testing.T, label string, b []byte, want []string) {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("%s: invalid json: %v\nbody: %s", label, err, b)
}
for _, k := range want {
if _, ok := m[k]; !ok {
t.Errorf("%s: missing key %q in %s", label, k, b)
}
}
}
func TestArtistSyncView_WireKeys(t *testing.T) {
a := dbq.Artist{ID: validUUID, Name: "Aphex Twin", SortName: "Aphex Twin"}
b, err := json.Marshal(toArtistSyncView(a))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "artist", b, []string{
"id", "name", "sort_name", "mbid",
"artist_thumb_path", "artist_fanart_path",
})
}
func TestAlbumSyncView_WireKeys(t *testing.T) {
a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"}
b, err := json.Marshal(toAlbumSyncView(a))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "album", b, []string{
"id", "artist_id", "title", "sort_title",
"release_date", "cover_art_path", "mbid",
})
}
func TestTrackSyncView_WireKeys(t *testing.T) {
tr := dbq.Track{
ID: validUUID, AlbumID: validUUID, ArtistID: validUUID,
Title: "Avril 14th", DurationMs: 121_000,
FilePath: "x", FileFormat: "flac",
}
b, err := json.Marshal(toTrackSyncView(tr))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "track", b, []string{
"id", "album_id", "artist_id", "title", "duration_ms",
"track_number", "disc_number", "file_path", "file_format", "genre",
})
}
func TestPlaylistSyncView_WireKeys(t *testing.T) {
variant := "discover"
p := dbq.Playlist{
ID: validUUID, UserID: validUUID, Name: "Test",
IsPublic: true, TrackCount: 5, DurationSec: 600,
SystemVariant: &variant,
}
b, err := json.Marshal(toPlaylistSyncView(p))
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "playlist", b, []string{
"id", "user_id", "name", "description",
"is_public", "cover_path", "track_count",
"duration_sec", "system_variant",
})
// Sanity: system_variant carries the value, not just present.
var m map[string]any
_ = json.Unmarshal(b, &m)
if got := m["system_variant"]; got != "discover" {
t.Errorf("system_variant: want 'discover', got %v", got)
}
}