|
- package rating
-
- import (
- "bytes"
- "encoding/binary"
- "os"
- "testing"
- )
-
- // buildTaggedMP3 builds a synthetic MP3 with a real ID3v2 tag.
- // - ver: 3 or 4 (ID3v2 minor version)
- // - frames: pre-built frame bytes (full frame including 10-byte frame header)
- // - paddingLen: bytes of zero padding inside the tag
- // - audio: payload after the tag
- //
- // The returned bytes are: 10-byte ID3 header | frames | padding | audio.
- func buildTaggedMP3(ver byte, frames []byte, paddingLen int, audio []byte) []byte {
- tagSize := len(frames) + paddingLen
- var hdr [10]byte
- hdr[0], hdr[1], hdr[2] = 'I', 'D', '3'
- hdr[3] = ver
- hdr[4] = 0
- hdr[5] = 0 // no flags
- s := uint32(tagSize)
- hdr[6] = byte((s >> 21) & 0x7F)
- hdr[7] = byte((s >> 14) & 0x7F)
- hdr[8] = byte((s >> 7) & 0x7F)
- hdr[9] = byte(s & 0x7F)
-
- out := make([]byte, 0, 10+tagSize+len(audio))
- out = append(out, hdr[:]...)
- out = append(out, frames...)
- out = append(out, make([]byte, paddingLen)...)
- out = append(out, audio...)
- return out
- }
-
- // makePOPMFrame builds a POPM frame matching ver's size encoding.
- func makePOPMFrame(ver byte, email string, rating byte, counter []byte) []byte {
- dataLen := len(email) + 1 + 1 + len(counter)
- out := make([]byte, 10+dataLen)
- copy(out[0:4], "POPM")
- switch ver {
- case 3:
- binary.BigEndian.PutUint32(out[4:8], uint32(dataLen))
- case 4:
- s := uint32(dataLen)
- out[4] = byte((s >> 21) & 0x7F)
- out[5] = byte((s >> 14) & 0x7F)
- out[6] = byte((s >> 7) & 0x7F)
- out[7] = byte(s & 0x7F)
- }
- copy(out[10:], email)
- out[10+len(email)] = 0
- out[10+len(email)+1] = rating
- copy(out[10+len(email)+2:], counter)
- return out
- }
-
- func fakeAudio(n int) []byte {
- a := make([]byte, n)
- for i := range a {
- a[i] = byte((i * 7) & 0xFF)
- }
- return a
- }
-
- // --- TESTS: patch path with existing POPM (1-byte write) ---------------------
-
- func TestPatch_existingPOPM_v23(t *testing.T) {
- for _, stars := range []int{1, 2, 3, 4, 5, 0} {
- t.Run("", func(t *testing.T) {
- // Use a starting rating (200) that doesn't match any starsToPOPM value
- // so we always observe a 1-byte change regardless of target stars.
- frame := makePOPMFrame(3, "rating@winamp.com", 200, []byte{0, 0, 0, 0})
- audio := fakeAudio(2048)
- data := buildTaggedMP3(3, frame, 512, audio)
- path := writeTempMP3(t, data)
- before, _ := os.ReadFile(path)
-
- if err := tryPatchPOPM(path, stars); err != nil {
- t.Fatalf("tryPatchPOPM: %v", err)
- }
-
- after, _ := os.ReadFile(path)
- if len(before) != len(after) {
- t.Fatalf("file size changed: %d → %d", len(before), len(after))
- }
-
- // Exactly ONE byte should differ — the rating byte.
- diffCount := 0
- diffOff := -1
- for i := range before {
- if before[i] != after[i] {
- diffCount++
- diffOff = i
- }
- }
- if diffCount != 1 {
- t.Fatalf("expected exactly 1 byte changed, got %d", diffCount)
- }
- // The rating byte sits at: 10 (header) + 10 (frame hdr) + len(email)+1
- wantOff := 10 + 10 + len("rating@winamp.com") + 1
- if diffOff != wantOff {
- t.Errorf("changed byte at %d, expected at %d", diffOff, wantOff)
- }
- if after[diffOff] != starsToPOPM(stars) {
- t.Errorf("rating byte = %d, want %d", after[diffOff], starsToPOPM(stars))
- }
-
- // Audio region untouched.
- audioStart := 10 + 10 + 23 + 512 // hdr + frame + padding
- if !bytes.Equal(after[audioStart:], audio) {
- t.Error("audio data corrupted")
- }
-
- // Get() should reflect the new rating
- got, _ := Get(path)
- if got != stars {
- t.Errorf("Get() = %d, want %d", got, stars)
- }
- })
- }
- }
-
- func TestPatch_existingPOPM_v24(t *testing.T) {
- frame := makePOPMFrame(4, "rating@winamp.com", 1, []byte{0, 0, 0, 0})
- audio := fakeAudio(1024)
- data := buildTaggedMP3(4, frame, 256, audio)
- path := writeTempMP3(t, data)
-
- if err := tryPatchPOPM(path, 5); err != nil {
- t.Fatalf("tryPatchPOPM: %v", err)
- }
- got, _ := Get(path)
- if got != 5 {
- t.Errorf("Get() = %d, want 5", got)
- }
- }
-
- // --- TESTS: patch path inserts new POPM into padding -------------------------
-
- func TestPatch_insertIntoPadding(t *testing.T) {
- audio := fakeAudio(1024)
- data := buildTaggedMP3(3, nil, 1024, audio) // empty tag body, all padding
- path := writeTempMP3(t, data)
- beforeSize := int64(len(data))
-
- if err := tryPatchPOPM(path, 4); err != nil {
- t.Fatalf("tryPatchPOPM: %v", err)
- }
-
- st, _ := os.Stat(path)
- if st.Size() != beforeSize {
- t.Fatalf("file size changed: %d → %d", beforeSize, st.Size())
- }
-
- got, err := Get(path)
- if err != nil {
- t.Fatalf("Get: %v", err)
- }
- if got != 4 {
- t.Errorf("Get() = %d, want 4", got)
- }
-
- // Audio untouched.
- after, _ := os.ReadFile(path)
- audioStart := 10 + 1024
- if !bytes.Equal(after[audioStart:], audio) {
- t.Error("audio data corrupted")
- }
- }
-
- // --- TESTS: patch path refuses safely on unsupported inputs ------------------
-
- func TestPatch_refusesNoID3(t *testing.T) {
- path := writeTempMP3(t, fakeAudio(2048))
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- func TestPatch_refusesUnsyncFlag(t *testing.T) {
- data := buildTaggedMP3(3, nil, 1024, fakeAudio(512))
- data[5] = 0x80 // set unsync flag
- path := writeTempMP3(t, data)
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- func TestPatch_refusesUnknownVersion(t *testing.T) {
- data := buildTaggedMP3(2, nil, 1024, fakeAudio(512))
- path := writeTempMP3(t, data)
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- func TestPatch_refusesPaddingTooSmall(t *testing.T) {
- // No POPM, only 5 bytes padding — new frame needs ~33 bytes.
- data := buildTaggedMP3(3, nil, 5, fakeAudio(512))
- path := writeTempMP3(t, data)
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- func TestPatch_refusesPOPMWithFlags(t *testing.T) {
- frame := makePOPMFrame(3, "rating@winamp.com", 128, []byte{0, 0, 0, 0})
- frame[9] = 0x01 // set a frame flag
- data := buildTaggedMP3(3, frame, 256, fakeAudio(512))
- path := writeTempMP3(t, data)
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- func TestPatch_refusesInvalidFrameID(t *testing.T) {
- // Garbage frame ID (lowercase invalid).
- bad := []byte{'a', 'b', 'c', 'd', 0, 0, 0, 4, 0, 0, 0, 0, 0, 0}
- data := buildTaggedMP3(3, bad, 256, fakeAudio(512))
- path := writeTempMP3(t, data)
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- func TestPatch_refusesPOPMOversizedSize(t *testing.T) {
- // POPM with size larger than what fits in the tag — must refuse, not panic.
- frame := makePOPMFrame(3, "rating@winamp.com", 128, []byte{0, 0, 0, 0})
- binary.BigEndian.PutUint32(frame[4:8], 999999) // lie about size
- data := buildTaggedMP3(3, frame, 256, fakeAudio(512))
- path := writeTempMP3(t, data)
- if err := tryPatchPOPM(path, 3); err != errPatchRefused {
- t.Errorf("got %v, want errPatchRefused", err)
- }
- }
-
- // --- TESTS: stars=0 semantics in patch path ----------------------------------
-
- func TestPatch_starsZeroWithNoFrame_noWrite(t *testing.T) {
- data := buildTaggedMP3(3, nil, 1024, fakeAudio(512))
- path := writeTempMP3(t, data)
- before, _ := os.ReadFile(path)
-
- if err := tryPatchPOPM(path, 0); err != nil {
- t.Fatalf("unexpected: %v", err)
- }
- after, _ := os.ReadFile(path)
- if !bytes.Equal(before, after) {
- t.Error("file changed even though stars=0 and no existing frame")
- }
- }
-
- // --- TESTS: walks past non-POPM frames correctly ----------------------------
-
- func TestPatch_skipsOtherFrames(t *testing.T) {
- // TIT2 (title) frame first, then POPM. Encoding byte 0x00 = ISO-8859-1.
- tit2Data := append([]byte{0x00}, "Test Title"...)
- tit2 := make([]byte, 10+len(tit2Data))
- copy(tit2[0:4], "TIT2")
- binary.BigEndian.PutUint32(tit2[4:8], uint32(len(tit2Data)))
- copy(tit2[10:], tit2Data)
-
- popm := makePOPMFrame(3, "rating@winamp.com", 1, []byte{0, 0, 0, 0})
- frames := append(tit2, popm...)
-
- audio := fakeAudio(1024)
- data := buildTaggedMP3(3, frames, 512, audio)
- path := writeTempMP3(t, data)
-
- if err := tryPatchPOPM(path, 5); err != nil {
- t.Fatalf("tryPatchPOPM: %v", err)
- }
- got, _ := Get(path)
- if got != 5 {
- t.Errorf("Get() = %d, want 5", got)
- }
-
- // Audio untouched.
- after, _ := os.ReadFile(path)
- audioStart := 10 + len(frames) + 512
- if !bytes.Equal(after[audioStart:], audio) {
- t.Error("audio data corrupted")
- }
- }
-
- // --- TESTS: Set() integration uses patch when it can ------------------------
-
- func TestSet_usesPatchPathWhenPossible(t *testing.T) {
- // File has padding → patch should succeed → audio MUST remain byte-identical.
- audio := fakeAudio(4096)
- data := buildTaggedMP3(3, nil, 2048, audio)
- path := writeTempMP3(t, data)
- beforeSize := int64(len(data))
-
- if err := Set(path, 3); err != nil {
- t.Fatalf("Set: %v", err)
- }
-
- st, _ := os.Stat(path)
- if st.Size() != beforeSize {
- t.Errorf("Set() with available padding changed file size: %d → %d (patch path should be used)",
- beforeSize, st.Size())
- }
-
- got, _ := Get(path)
- if got != 3 {
- t.Errorf("Get() = %d, want 3", got)
- }
- }
|