test(16-02): add FLAC writer round-trip tests with 7 test functions

- TestWriteFlacTags_TextFields: round-trip 9 text fields via metadata.ExtractTags
- TestWriteFlacTags_CoverArt: JPEG embed and read-back verification
- TestWriteFlacTags_ClearCoverArt: embed then clear art, verify removal
- TestWriteFlacTags_PartialUpdate: update 2 of 8 fields, verify others preserved
- TestWriteFlacTags_PreservesStreamInfo: verify StreamInfo block survives tag writes
- TestWriteFlacTags_ReplaceComment: write twice, verify no duplicate Vorbis Comments
- TestWriteFlacTags_AtomicSafety: verify corrupt/invalid writes leave original intact
- Create shared helpers_test.go with testLogger, tinyJPEG, assertEqual, etc.
This commit is contained in:
2026-03-17 10:32:46 -04:00
parent 3642cbe0d5
commit a677a44d49
2 changed files with 531 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
package tagwriter
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
flac "github.com/go-flac/go-flac/v2"
"yellowjacket/backend/metadata"
)
// Test-only sentinel errors for the Vorbis Comment parser.
var (
errVorbisCommentTooShort = errors.New("vorbis comment too short")
errVorbisCommentTruncated = errors.New("vorbis comment truncated")
)
// makeMinimalFLAC creates a minimal valid FLAC file at path.
// The file contains: fLaC header, StreamInfo metadata block, and a
// minimal valid FLAC frame (sync code + padding).
func makeMinimalFLAC(t *testing.T, path string) {
t.Helper()
var buf bytes.Buffer
// fLaC magic.
buf.WriteString("fLaC")
// StreamInfo metadata block (type=0, last=true → 0x80).
// StreamInfo data is exactly 34 bytes.
streamInfo := makeStreamInfoData()
// Block header: type byte (0x80 = last + StreamInfo), 3-byte length.
buf.WriteByte(0x80) // last-metadata-block flag | StreamInfo type
// 3-byte big-endian length of StreamInfo data.
siLen := uint32(len(streamInfo))
buf.WriteByte(byte(siLen >> 16))
buf.WriteByte(byte(siLen >> 8))
buf.WriteByte(byte(siLen))
buf.Write(streamInfo)
// Minimal FLAC frame — sync code 0xFFF8 + enough padding bytes to
// form a parseable (though silent) frame. The go-flac library only
// checks for the sync code (0xFF, byte>>2 == 0x3E) when parsing frames.
buf.Write([]byte{0xFF, 0xF8}) // sync code
// Add some zero padding as a pseudo-frame body.
buf.Write(make([]byte, 16))
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write minimal FLAC: %v", err)
}
}
// makeStreamInfoData creates a 34-byte StreamInfo metadata block body:
// - min/max block size: 4096
// - min/max frame size: 0
// - sample rate: 44100, channels: 1 (mono), bits per sample: 16
// - total samples: 0
// - MD5: all zeros.
func makeStreamInfoData() []byte {
var buf bytes.Buffer
_ = binary.Write(&buf, binary.BigEndian, uint16(4096)) // min block size
_ = binary.Write(&buf, binary.BigEndian, uint16(4096)) // max block size
// min frame size (3 bytes, big-endian).
buf.Write([]byte{0, 0, 0})
// max frame size (3 bytes, big-endian).
buf.Write([]byte{0, 0, 0})
// Next 8 bytes pack: sample rate (20 bits), channels-1 (3 bits),
// bits-per-sample-1 (5 bits), total samples (36 bits).
//
// 44100 Hz, 1 channel, 16 bps, 0 total samples.
buf.Write([]byte{
0xAC, 0x44, 0x00, 0xF0,
0x00, 0x00, 0x00, 0x00,
})
// MD5 signature (16 bytes of zeros).
buf.Write(make([]byte, 16))
return buf.Bytes()
}
func TestWriteFlacTags_TextFields(t *testing.T) {
tmpDir := t.TempDir()
flacPath := filepath.Join(tmpDir, "test.flac")
makeMinimalFLAC(t, flacPath)
changes := TagChanges{
FieldTitle: "Test Title",
FieldArtist: "Test Artist",
FieldAlbum: "Test Album",
FieldAlbumArtist: "Test Album Artist",
FieldGenre: "Rock",
FieldYear: 2024,
FieldTrackNumber: 3,
FieldDiscNumber: 1,
FieldComposer: "Test Composer",
}
if err := writeFlacTags(testLogger(), flacPath, changes); err != nil {
t.Fatalf("writeFlacTags: %v", err)
}
// Read back with metadata.ExtractTags to verify round-trip.
tags, err := metadata.ExtractTags(flacPath)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
assertEqual(t, "Title", "Test Title", tags.Title)
assertEqual(t, "Artist", "Test Artist", tags.Artist)
assertEqual(t, "Album", "Test Album", tags.Album)
assertEqual(t, "AlbumArtist", "Test Album Artist", tags.AlbumArtist)
assertEqual(t, "Genre", "Rock", tags.Genre)
assertEqual(t, "Composer", "Test Composer", tags.Composer)
assertEqual(t, "Year", 2024, tags.Year)
assertEqual(t, "TrackNumber", 3, tags.TrackNumber)
assertEqual(t, "DiscNumber", 1, tags.DiscNumber)
}
func TestWriteFlacTags_CoverArt(t *testing.T) {
tmpDir := t.TempDir()
flacPath := filepath.Join(tmpDir, "test.flac")
makeMinimalFLAC(t, flacPath)
jpegData := tinyJPEG(t)
changes := TagChanges{
FieldCoverArt: jpegData,
}
if err := writeFlacTags(testLogger(), flacPath, changes); err != nil {
t.Fatalf("writeFlacTags: %v", err)
}
tags, err := metadata.ExtractTags(flacPath)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if tags.Picture == nil {
t.Fatal("expected picture data, got nil")
}
if !bytes.Equal(tags.Picture.Data, jpegData) {
t.Errorf("picture data mismatch: got %d bytes, want %d bytes",
len(tags.Picture.Data), len(jpegData))
}
assertEqual(t, "MIME type", "image/jpeg", tags.Picture.MIMEType)
}
func TestWriteFlacTags_ClearCoverArt(t *testing.T) {
tmpDir := t.TempDir()
flacPath := filepath.Join(tmpDir, "test.flac")
makeMinimalFLAC(t, flacPath)
// First add cover art.
jpegData := tinyJPEG(t)
err := writeFlacTags(testLogger(), flacPath, TagChanges{FieldCoverArt: jpegData})
if err != nil {
t.Fatalf("add cover art: %v", err)
}
// Verify art was added.
tags, err := metadata.ExtractTags(flacPath)
if err != nil {
t.Fatalf("ExtractTags after add: %v", err)
}
if tags.Picture == nil {
t.Fatal("expected picture after add, got nil")
}
// Now clear cover art (nil value).
if err := writeFlacTags(testLogger(), flacPath, TagChanges{FieldCoverArt: nil}); err != nil {
t.Fatalf("clear cover art: %v", err)
}
tags, err = metadata.ExtractTags(flacPath)
if err != nil {
t.Fatalf("ExtractTags after clear: %v", err)
}
if tags.Picture != nil {
t.Errorf("expected no picture after clear, got %d bytes", len(tags.Picture.Data))
}
}
func TestWriteFlacTags_PartialUpdate(t *testing.T) {
tmpDir := t.TempDir()
flacPath := filepath.Join(tmpDir, "test.flac")
makeMinimalFLAC(t, flacPath)
// Write all fields first.
allFields := TagChanges{
FieldTitle: "Original Title",
FieldArtist: "Original Artist",
FieldAlbum: "Original Album",
FieldGenre: "Jazz",
FieldYear: 2020,
FieldTrackNumber: 5,
FieldDiscNumber: 2,
FieldComposer: "Original Composer",
}
if err := writeFlacTags(testLogger(), flacPath, allFields); err != nil {
t.Fatalf("write all fields: %v", err)
}
// Update only title and genre.
partial := TagChanges{
FieldTitle: "Updated Title",
FieldGenre: "Blues",
}
if err := writeFlacTags(testLogger(), flacPath, partial); err != nil {
t.Fatalf("partial update: %v", err)
}
tags, err := metadata.ExtractTags(flacPath)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
// Changed fields should have new values.
assertEqual(t, "Title", "Updated Title", tags.Title)
assertEqual(t, "Genre", "Blues", tags.Genre)
// Unchanged fields should be preserved.
assertEqual(t, "Artist", "Original Artist", tags.Artist)
assertEqual(t, "Album", "Original Album", tags.Album)
assertEqual(t, "Year", 2020, tags.Year)
assertEqual(t, "TrackNumber", 5, tags.TrackNumber)
assertEqual(t, "DiscNumber", 2, tags.DiscNumber)
assertEqual(t, "Composer", "Original Composer", tags.Composer)
}
func TestWriteFlacTags_PreservesStreamInfo(t *testing.T) {
tmpDir := t.TempDir()
flacPath := filepath.Join(tmpDir, "test.flac")
makeMinimalFLAC(t, flacPath)
// Parse before writing to capture original StreamInfo.
fBefore, err := flac.ParseFile(flacPath)
if err != nil {
t.Fatalf("parse before: %v", err)
}
siBefore, err := fBefore.GetStreamInfo()
if err != nil {
t.Fatalf("get stream info before: %v", err)
}
_ = fBefore.Close()
// Write tags.
changes := TagChanges{
FieldTitle: "Some Title",
FieldArtist: "Some Artist",
}
if err := writeFlacTags(testLogger(), flacPath, changes); err != nil {
t.Fatalf("writeFlacTags: %v", err)
}
// Parse after writing and verify StreamInfo is intact.
fAfter, err := flac.ParseFile(flacPath)
if err != nil {
t.Fatalf("parse after: %v", err)
}
defer func() { _ = fAfter.Close() }()
siAfter, err := fAfter.GetStreamInfo()
if err != nil {
t.Fatalf("get stream info after: %v", err)
}
assertEqual(t, "SampleRate", siBefore.SampleRate, siAfter.SampleRate)
assertEqual(t, "ChannelCount", siBefore.ChannelCount, siAfter.ChannelCount)
assertEqual(t, "BitDepth", siBefore.BitDepth, siAfter.BitDepth)
assertEqual(t, "BlockSizeMin", siBefore.BlockSizeMin, siAfter.BlockSizeMin)
// Verify the file has StreamInfo as the first metadata block.
if fAfter.Meta[0].Type != flac.StreamInfo {
t.Errorf("first metadata block type: got %d, want %d (StreamInfo)",
fAfter.Meta[0].Type, flac.StreamInfo)
}
}
func TestWriteFlacTags_ReplaceComment(t *testing.T) {
tmpDir := t.TempDir()
flacPath := filepath.Join(tmpDir, "test.flac")
makeMinimalFLAC(t, flacPath)
// Write title once.
if err := writeFlacTags(testLogger(), flacPath, TagChanges{FieldTitle: "First"}); err != nil {
t.Fatalf("first write: %v", err)
}
// Write title again.
if err := writeFlacTags(testLogger(), flacPath, TagChanges{FieldTitle: "Second"}); err != nil {
t.Fatalf("second write: %v", err)
}
// Parse the FLAC and check no duplicate TITLE entries.
f, err := flac.ParseFile(flacPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
defer func() { _ = f.Close() }()
for _, meta := range f.Meta {
if meta.Type != flac.VorbisComment {
continue
}
cmt, parseErr := parseVorbisComment(*meta)
if parseErr != nil {
t.Fatalf("parse vorbis comments: %v", parseErr)
}
titles := cmt.get("TITLE")
if len(titles) != 1 {
t.Errorf("expected 1 TITLE entry, got %d: %v", len(titles), titles)
}
if len(titles) > 0 && titles[0] != "Second" {
t.Errorf("TITLE value: got %q, want %q", titles[0], "Second")
}
break
}
// Also verify via metadata.ExtractTags round-trip.
tags, err := metadata.ExtractTags(flacPath)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
assertEqual(t, "Title", "Second", tags.Title)
}
func TestWriteFlacTags_AtomicSafety(t *testing.T) {
tmpDir := t.TempDir()
// Verify that writing to a corrupt (non-FLAC) file fails and
// leaves the file untouched.
corruptPath := filepath.Join(tmpDir, "corrupt.flac")
if err := os.WriteFile(corruptPath, []byte("not a flac file"), 0o644); err != nil {
t.Fatalf("write corrupt: %v", err)
}
err := writeFlacTags(testLogger(), corruptPath, TagChanges{FieldTitle: "Fail"})
if err == nil {
t.Fatal("expected error writing to corrupt file, got nil")
}
// The corrupt file should remain untouched.
content, readErr := os.ReadFile(corruptPath)
if readErr != nil {
t.Fatalf("read corrupt file: %v", readErr)
}
if string(content) != "not a flac file" {
t.Errorf("corrupt file was modified: got %q", string(content))
}
// Verify that a valid FLAC file stays intact when the write fails
// due to invalid cover art data.
safePath := filepath.Join(tmpDir, "safe.flac")
makeMinimalFLAC(t, safePath)
safeBefore, readErr := os.ReadFile(safePath)
if readErr != nil {
t.Fatalf("read safe before: %v", readErr)
}
// Provide random bytes that aren't valid JPEG or PNG — this will
// cause flacpicture.NewFromImageData to fail, which happens before
// AtomicWrite so the original file stays intact.
randomData := make([]byte, 32)
_, _ = rand.Read(randomData)
err = writeFlacTags(testLogger(), safePath, TagChanges{FieldCoverArt: randomData})
if err == nil {
t.Fatal("expected error with invalid image data, got nil")
}
safeAfter, readErr := os.ReadFile(safePath)
if readErr != nil {
t.Fatalf("read safe after: %v", readErr)
}
if !bytes.Equal(safeBefore, safeAfter) {
t.Error("safe file was modified despite failed write")
}
}
// parseVorbisComment is a test helper to parse Vorbis Comments from a raw
// metadata block using a minimal inline parser.
func parseVorbisComment(meta flac.MetaDataBlock) (*vorbisCommentHelper, error) {
result := &vorbisCommentHelper{comments: map[string][]string{}}
data := meta.Data
if len(data) < 4 { //nolint:mnd
return nil, errVorbisCommentTooShort
}
// Vendor string length (little-endian 32-bit).
vendorLen := int(binary.LittleEndian.Uint32(data[0:4]))
offset := 4 + vendorLen
if offset+4 > len(data) {
return nil, fmt.Errorf("%w: after vendor string", errVorbisCommentTruncated)
}
// Comment count.
count := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
for range count {
if offset+4 > len(data) {
return nil, fmt.Errorf("%w: comment header", errVorbisCommentTruncated)
}
cmtLen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
if offset+cmtLen > len(data) {
return nil, fmt.Errorf("%w: comment entry", errVorbisCommentTruncated)
}
entry := string(data[offset : offset+cmtLen])
offset += cmtLen
key, val, found := strings.Cut(entry, "=")
if !found {
continue
}
result.comments[key] = append(result.comments[key], val)
}
return result, nil
}
type vorbisCommentHelper struct {
comments map[string][]string
}
func (v *vorbisCommentHelper) get(key string) []string {
return v.comments[key]
}
+65
View File
@@ -0,0 +1,65 @@
package tagwriter
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"log/slog"
"os"
"testing"
)
// testLogger returns a quiet logger suitable for tests.
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelError,
}))
}
// tinyJPEG returns a small, valid JPEG image for cover art tests.
func tinyJPEG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 255, A: 255})
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatalf("encode tiny jpeg: %v", err)
}
return buf.Bytes()
}
// makeMinimalJPEG is an alias for tinyJPEG used by mp3_test.go.
func makeMinimalJPEG(t *testing.T) []byte {
t.Helper()
return tinyJPEG(t)
}
// assertEqual is a generic test helper for comparing values.
func assertEqual[T comparable](t *testing.T, field string, want, got T) {
t.Helper()
if got != want {
t.Errorf("%s: got %v, want %v", field, got, want)
}
}
func assertStrField(t *testing.T, name, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s: got %q, want %q", name, got, want)
}
}
func assertIntField(t *testing.T, name string, got, want int) {
t.Helper()
if got != want {
t.Errorf("%s: got %d, want %d", name, got, want)
}
}