test(20-02): add round-trip tests for all OGG requirements
- TestWriteOggTags_TextFields: all 9 text fields round-trip via dhowden/tag (OGG-01) - TestWriteOggTags_CoverArt: METADATA_BLOCK_PICTURE embed verified (OGG-04) - TestWriteOggTags_ClearCoverArt: cover art clear via nil (OGG-04) - TestWriteOggTags_PartialUpdate: non-edited fields preserved (OGG-02) - TestWriteOggTags_AudioPreservation: audio page data byte-identical (OGG-03) - TestWriteOggTags_AtomicSafety: corrupt file untouched on failure (OGG-05) - TestWriteOggTags_RejectNonVorbis: Theora OGG rejected (OGG-03 error path) - TestWriteOggTags_RejectMultiStream: multi-serial rejected
This commit is contained in:
@@ -251,3 +251,409 @@ func TestCreateTestOGG_Valid(t *testing.T) {
|
|||||||
t.Fatalf("metadata.ExtractTags on empty fixture: %v", err)
|
t.Fatalf("metadata.ExtractTags on empty fixture: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Round-trip tests for OGG requirements (OGG-01 through OGG-06)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWriteOggTags_TextFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "text.ogg")
|
||||||
|
createTestOGG(t, path)
|
||||||
|
|
||||||
|
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 := writeOggTags(testLogger(), path, changes); err != nil {
|
||||||
|
t.Fatalf("writeOggTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back with metadata.ExtractTags (dhowden/tag).
|
||||||
|
tags, err := metadata.ExtractTags(path)
|
||||||
|
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 TestWriteOggTags_CoverArt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "cover.ogg")
|
||||||
|
createTestOGG(t, path)
|
||||||
|
|
||||||
|
jpegData := tinyJPEG(t)
|
||||||
|
|
||||||
|
changes := TagChanges{
|
||||||
|
FieldCoverArt: jpegData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOggTags(testLogger(), path, changes); err != nil {
|
||||||
|
t.Fatalf("writeOggTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := metadata.ExtractTags(path)
|
||||||
|
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 TestWriteOggTags_ClearCoverArt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "clear_art.ogg")
|
||||||
|
createTestOGG(t, path)
|
||||||
|
|
||||||
|
// First add cover art.
|
||||||
|
jpegData := tinyJPEG(t)
|
||||||
|
|
||||||
|
err := writeOggTags(testLogger(), path, TagChanges{FieldCoverArt: jpegData})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add cover art: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify art was added.
|
||||||
|
tags, err := metadata.ExtractTags(path)
|
||||||
|
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 := writeOggTags(testLogger(), path, TagChanges{FieldCoverArt: nil}); err != nil {
|
||||||
|
t.Fatalf("clear cover art: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err = metadata.ExtractTags(path)
|
||||||
|
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 TestWriteOggTags_PartialUpdate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "partial.ogg")
|
||||||
|
createTestOGG(t, path)
|
||||||
|
|
||||||
|
// Write all fields first.
|
||||||
|
allFields := TagChanges{
|
||||||
|
FieldTitle: "Original Title",
|
||||||
|
FieldArtist: "Original Artist",
|
||||||
|
FieldAlbum: "Original Album",
|
||||||
|
FieldAlbumArtist: "Original AA",
|
||||||
|
FieldGenre: "Jazz",
|
||||||
|
FieldYear: 2020,
|
||||||
|
FieldTrackNumber: 5,
|
||||||
|
FieldDiscNumber: 2,
|
||||||
|
FieldComposer: "Original Composer",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOggTags(testLogger(), path, allFields); err != nil {
|
||||||
|
t.Fatalf("write all fields: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only title and genre.
|
||||||
|
partial := TagChanges{
|
||||||
|
FieldTitle: "Updated Title",
|
||||||
|
FieldGenre: "Blues",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOggTags(testLogger(), path, partial); err != nil {
|
||||||
|
t.Fatalf("partial update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := metadata.ExtractTags(path)
|
||||||
|
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, "AlbumArtist", "Original AA", tags.AlbumArtist)
|
||||||
|
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 TestWriteOggTags_AudioPreservation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "audio.ogg")
|
||||||
|
createTestOGG(t, path)
|
||||||
|
|
||||||
|
// Capture original audio page data.
|
||||||
|
pagesBefore, err := parseOggPages(testLogger(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOggPages before: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioStartBefore := findAudioPageStart(pagesBefore)
|
||||||
|
|
||||||
|
var originalAudioData []byte
|
||||||
|
for _, p := range pagesBefore[audioStartBefore:] {
|
||||||
|
originalAudioData = append(originalAudioData, p.data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(originalAudioData) == 0 {
|
||||||
|
t.Fatal("no audio data found in fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write tags.
|
||||||
|
changes := TagChanges{
|
||||||
|
FieldTitle: "Audio Test",
|
||||||
|
FieldArtist: "Audio Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOggTags(testLogger(), path, changes); err != nil {
|
||||||
|
t.Fatalf("writeOggTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse again and compare audio data.
|
||||||
|
pagesAfter, err := parseOggPages(testLogger(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOggPages after: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioStartAfter := findAudioPageStart(pagesAfter)
|
||||||
|
|
||||||
|
var newAudioData []byte
|
||||||
|
for _, p := range pagesAfter[audioStartAfter:] {
|
||||||
|
newAudioData = append(newAudioData, p.data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(originalAudioData, newAudioData) {
|
||||||
|
t.Errorf("audio data changed after tag write: before=%d bytes, after=%d bytes",
|
||||||
|
len(originalAudioData), len(newAudioData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOggTags_AtomicSafety(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Test 1: Corrupt (non-OGG) file — should fail and leave file untouched.
|
||||||
|
corruptPath := filepath.Join(dir, "corrupt.ogg")
|
||||||
|
corruptContent := []byte("not an ogg file at all")
|
||||||
|
|
||||||
|
if err := os.WriteFile(corruptPath, corruptContent, 0o644); err != nil {
|
||||||
|
t.Fatalf("write corrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeOggTags(testLogger(), corruptPath, TagChanges{FieldTitle: "Fail"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error writing to corrupt file, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify corrupt file is untouched.
|
||||||
|
content, readErr := os.ReadFile(corruptPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read corrupt: %v", readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(content, corruptContent) {
|
||||||
|
t.Error("corrupt file was modified despite write failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Valid OGG → write to non-existent directory path should fail.
|
||||||
|
validPath := filepath.Join(dir, "valid.ogg")
|
||||||
|
createTestOGG(t, validPath)
|
||||||
|
|
||||||
|
originalData, err := os.ReadFile(validPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read valid before: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
badPath := filepath.Join(dir, "nonexistent", "subdir", "file.ogg")
|
||||||
|
|
||||||
|
writeErr := writeOggTags(testLogger(), badPath, TagChanges{FieldTitle: "Fail"})
|
||||||
|
if writeErr == nil {
|
||||||
|
t.Fatal("expected error for non-existent path, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the original valid file is unchanged.
|
||||||
|
afterData, err := os.ReadFile(validPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read valid after: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(originalData, afterData) {
|
||||||
|
t.Error("valid file was modified despite failure elsewhere")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOggTags_RejectNonVorbis(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "theora.ogg")
|
||||||
|
|
||||||
|
// Build a valid OGG page structure with a Theora identification header
|
||||||
|
// instead of Vorbis.
|
||||||
|
const serialNo uint32 = 0xDEADBEEF
|
||||||
|
|
||||||
|
theoraIdent := make([]byte, 30)
|
||||||
|
theoraIdent[0] = 0x80 // Theora ID header type
|
||||||
|
copy(theoraIdent[1:7], "theora")
|
||||||
|
|
||||||
|
identSegs := splitPacketIntoSegments(theoraIdent)
|
||||||
|
identPage := oggPage{
|
||||||
|
headerType: 0x02, // bos
|
||||||
|
granulePos: 0,
|
||||||
|
serialNo: serialNo,
|
||||||
|
seqNo: 0,
|
||||||
|
segmentTable: identSegs,
|
||||||
|
data: theoraIdent,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
if err := writeOggPage(&buf, identPage); err != nil {
|
||||||
|
t.Fatalf("write theora page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an EOS page.
|
||||||
|
eosPage := oggPage{
|
||||||
|
headerType: 0x04,
|
||||||
|
granulePos: 0,
|
||||||
|
serialNo: serialNo,
|
||||||
|
seqNo: 1,
|
||||||
|
segmentTable: []byte{0},
|
||||||
|
data: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOggPage(&buf, eosPage); err != nil {
|
||||||
|
t.Fatalf("write eos page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write theora OGG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeOggTags(testLogger(), path, TagChanges{FieldTitle: "Should Fail"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-Vorbis OGG, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errorContains(err, "not an OGG Vorbis") {
|
||||||
|
t.Errorf("error should mention non-Vorbis, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOggTags_RejectMultiStream(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "multi.ogg")
|
||||||
|
|
||||||
|
// Build an OGG file with pages from two different serial numbers.
|
||||||
|
// First page is a valid Vorbis bos page.
|
||||||
|
identPacket := buildVorbisIdentPacket()
|
||||||
|
identSegs := splitPacketIntoSegments(identPacket)
|
||||||
|
|
||||||
|
const serial1 uint32 = 0x11111111
|
||||||
|
const serial2 uint32 = 0x22222222
|
||||||
|
|
||||||
|
page1 := oggPage{
|
||||||
|
headerType: 0x02, // bos
|
||||||
|
granulePos: 0,
|
||||||
|
serialNo: serial1,
|
||||||
|
seqNo: 0,
|
||||||
|
segmentTable: identSegs,
|
||||||
|
data: identPacket,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second bos page with a different serial number.
|
||||||
|
page2 := oggPage{
|
||||||
|
headerType: 0x02, // bos
|
||||||
|
granulePos: 0,
|
||||||
|
serialNo: serial2,
|
||||||
|
seqNo: 0,
|
||||||
|
segmentTable: identSegs,
|
||||||
|
data: identPacket,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
if err := writeOggPage(&buf, page1); err != nil {
|
||||||
|
t.Fatalf("write page1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOggPage(&buf, page2); err != nil {
|
||||||
|
t.Fatalf("write page2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write multi-stream OGG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeOggTags(testLogger(), path, TagChanges{FieldTitle: "Should Fail"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for multi-stream OGG, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errorContains(err, "multiple streams") {
|
||||||
|
t.Errorf("error should mention multiple streams, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorContains checks if an error's message contains the given substring.
|
||||||
|
func errorContains(err error, substr string) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.Contains(
|
||||||
|
[]byte(err.Error()),
|
||||||
|
[]byte(substr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user