TASK-ETH-001: 4 discovery bias tests (genre/tag browse, emerging artist visibility, metrics not exposed in JSON). Verifies chronological ordering regardless of play count. TASK-ETH-002: 4 search fairness tests (artist 0 plays discoverable, zero-play tracks not filtered, default sort is chronological, no popularity bias in default ranking). TASK-ETH-003: veza-docs/DISCOVERY_ALGORITHM.md — documents all 6 discovery mechanisms, ethical constraints, and forbidden patterns per ORIGIN specs. TASK-COV-001: CI coverage gates — Go >= 70% (backend-ci.yml), Rust >= 50% (rust-ci.yml with cargo-tarpaulin). Extended Go test scope to core/ and middleware/. TASK-COV-002: Coverage badge JSON artifact on main push (shields.io compatible). All 8 ethical tests PASS. Build clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
268 lines
7.8 KiB
Go
268 lines
7.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TestSearch_ArtistZeroPlays_Discoverable verifies that an artist with 0 plays
|
|
// appears in search results when searched by name.
|
|
// TASK-ETH-002: ORIGIN_FEATURES_REGISTRY.md F375 — search must not require play count.
|
|
func TestSearch_ArtistZeroPlays_Discoverable(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
err = db.AutoMigrate(&models.Track{}, &models.User{})
|
|
require.NoError(t, err)
|
|
|
|
service := NewTrackSearchService(db)
|
|
ctx := context.Background()
|
|
|
|
// Artist with 0 plays — emerging artist
|
|
emergingID := uuid.New()
|
|
err = db.Create(&models.User{ID: emergingID, Username: "emerging_talent", Email: "emerging@test.com", IsActive: true}).Error
|
|
require.NoError(t, err)
|
|
|
|
// Create track with 0 plays
|
|
track := &models.Track{
|
|
UserID: emergingID,
|
|
Title: "My First Song",
|
|
Artist: "emerging_talent",
|
|
FilePath: "/test/first.mp3",
|
|
FileSize: 4 * 1024 * 1024,
|
|
Format: "MP3",
|
|
Duration: 200,
|
|
IsPublic: true,
|
|
Status: models.TrackStatusCompleted,
|
|
PlayCount: 0,
|
|
LikeCount: 0,
|
|
}
|
|
err = db.Create(track).Error
|
|
require.NoError(t, err)
|
|
|
|
// Search by artist name
|
|
results, total, err := service.SearchTracks(ctx, TrackSearchParams{
|
|
Query: "emerging_talent",
|
|
Page: 1,
|
|
Limit: 10,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), total, "artist with 0 plays must appear in search results")
|
|
require.Len(t, results, 1)
|
|
assert.Equal(t, "My First Song", results[0].Title)
|
|
}
|
|
|
|
// TestSearch_ZeroPlaysTrack_NotFilteredOut verifies that tracks with 0 play count
|
|
// are not filtered from search results by any implicit popularity filter.
|
|
// TASK-ETH-002: no play-count minimum for searchability.
|
|
func TestSearch_ZeroPlaysTrack_NotFilteredOut(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
err = db.AutoMigrate(&models.Track{}, &models.User{})
|
|
require.NoError(t, err)
|
|
|
|
service := NewTrackSearchService(db)
|
|
ctx := context.Background()
|
|
|
|
userID := uuid.New()
|
|
err = db.Create(&models.User{ID: userID, Username: "testuser", Email: "t@test.com", IsActive: true}).Error
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
|
|
// Create tracks with varying play counts
|
|
tracks := []struct {
|
|
title string
|
|
playCount int64
|
|
createdAt time.Time
|
|
}{
|
|
{"Zero Plays Song", 0, now},
|
|
{"One Play Song", 1, now.Add(-1 * time.Hour)},
|
|
{"Popular Song", 100_000, now.Add(-2 * time.Hour)},
|
|
}
|
|
|
|
for _, tc := range tracks {
|
|
err = db.Create(&models.Track{
|
|
UserID: userID,
|
|
Title: tc.title,
|
|
Artist: "Test Artist",
|
|
FilePath: "/test/" + tc.title + ".mp3",
|
|
FileSize: 5 * 1024 * 1024,
|
|
Format: "MP3",
|
|
Duration: 180,
|
|
IsPublic: true,
|
|
Status: models.TrackStatusCompleted,
|
|
PlayCount: tc.playCount,
|
|
CreatedAt: tc.createdAt,
|
|
}).Error
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Search for "Song" — all 3 must appear regardless of play count
|
|
results, total, err := service.SearchTracks(ctx, TrackSearchParams{
|
|
Query: "Song",
|
|
Page: 1,
|
|
Limit: 10,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), total, "all tracks must appear regardless of play count")
|
|
assert.Len(t, results, 3)
|
|
|
|
// Verify all titles are present
|
|
titles := make([]string, len(results))
|
|
for i, r := range results {
|
|
titles[i] = r.Title
|
|
}
|
|
assert.Contains(t, titles, "Zero Plays Song", "0-play track must be in results")
|
|
assert.Contains(t, titles, "One Play Song", "1-play track must be in results")
|
|
assert.Contains(t, titles, "Popular Song", "popular track must be in results")
|
|
}
|
|
|
|
// TestSearch_DefaultSortIsChronological verifies that the default sort order
|
|
// is created_at DESC (chronological), not by popularity or engagement.
|
|
// TASK-ETH-002: ORIGIN_BUSINESS_LOGIC.md — no engagement optimization.
|
|
func TestSearch_DefaultSortIsChronological(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
err = db.AutoMigrate(&models.Track{}, &models.User{})
|
|
require.NoError(t, err)
|
|
|
|
service := NewTrackSearchService(db)
|
|
ctx := context.Background()
|
|
|
|
userID := uuid.New()
|
|
err = db.Create(&models.User{ID: userID, Username: "testuser", Email: "t@test.com", IsActive: true}).Error
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
|
|
// Create tracks: popular one is oldest, unpopular one is newest
|
|
oldPopular := &models.Track{
|
|
UserID: userID,
|
|
Title: "Old Popular",
|
|
Artist: "Artist",
|
|
FilePath: "/test/old.mp3",
|
|
FileSize: 5 * 1024 * 1024,
|
|
Format: "MP3",
|
|
Duration: 180,
|
|
IsPublic: true,
|
|
Status: models.TrackStatusCompleted,
|
|
PlayCount: 1_000_000,
|
|
CreatedAt: now.Add(-24 * time.Hour),
|
|
}
|
|
err = db.Create(oldPopular).Error
|
|
require.NoError(t, err)
|
|
|
|
newUnpopular := &models.Track{
|
|
UserID: userID,
|
|
Title: "New Unpopular",
|
|
Artist: "Artist",
|
|
FilePath: "/test/new.mp3",
|
|
FileSize: 5 * 1024 * 1024,
|
|
Format: "MP3",
|
|
Duration: 180,
|
|
IsPublic: true,
|
|
Status: models.TrackStatusCompleted,
|
|
PlayCount: 0,
|
|
CreatedAt: now,
|
|
}
|
|
err = db.Create(newUnpopular).Error
|
|
require.NoError(t, err)
|
|
|
|
// Default search (no SortBy specified) — should be created_at DESC
|
|
results, _, err := service.SearchTracks(ctx, TrackSearchParams{
|
|
Page: 1,
|
|
Limit: 10,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, results, 2)
|
|
assert.Equal(t, "New Unpopular", results[0].Title, "default sort must be chronological (newest first), not popularity")
|
|
assert.Equal(t, "Old Popular", results[1].Title)
|
|
}
|
|
|
|
// TestSearch_NoPopularityBias_InDefaultRanking verifies that when no sort
|
|
// is specified, results are not biased by play_count or like_count.
|
|
// TASK-ETH-002: ethical search — equal visibility for all artists.
|
|
func TestSearch_NoPopularityBias_InDefaultRanking(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
err = db.AutoMigrate(&models.Track{}, &models.User{})
|
|
require.NoError(t, err)
|
|
|
|
service := NewTrackSearchService(db)
|
|
ctx := context.Background()
|
|
|
|
// Two different artists
|
|
emergingID := uuid.New()
|
|
err = db.Create(&models.User{ID: emergingID, Username: "new_artist", Email: "new@test.com", IsActive: true}).Error
|
|
require.NoError(t, err)
|
|
|
|
starID := uuid.New()
|
|
err = db.Create(&models.User{ID: starID, Username: "mega_star", Email: "star@test.com", IsActive: true}).Error
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
|
|
// Emerging artist's track (newest, genre=jazz)
|
|
err = db.Create(&models.Track{
|
|
UserID: emergingID,
|
|
Title: "Jazz Improvisation",
|
|
Artist: "new_artist",
|
|
FilePath: "/test/jazz1.mp3",
|
|
FileSize: 5 * 1024 * 1024,
|
|
Format: "MP3",
|
|
Duration: 300,
|
|
Genre: "jazz",
|
|
IsPublic: true,
|
|
Status: models.TrackStatusCompleted,
|
|
PlayCount: 0,
|
|
LikeCount: 0,
|
|
CreatedAt: now,
|
|
}).Error
|
|
require.NoError(t, err)
|
|
|
|
// Star's track (older, genre=jazz)
|
|
err = db.Create(&models.Track{
|
|
UserID: starID,
|
|
Title: "Jazz Standards",
|
|
Artist: "mega_star",
|
|
FilePath: "/test/jazz2.mp3",
|
|
FileSize: 5 * 1024 * 1024,
|
|
Format: "MP3",
|
|
Duration: 250,
|
|
Genre: "jazz",
|
|
IsPublic: true,
|
|
Status: models.TrackStatusCompleted,
|
|
PlayCount: 5_000_000,
|
|
LikeCount: 200_000,
|
|
CreatedAt: now.Add(-1 * time.Hour),
|
|
}).Error
|
|
require.NoError(t, err)
|
|
|
|
// Search for "Jazz" — default sort
|
|
genre := "jazz"
|
|
results, _, err := service.SearchTracks(ctx, TrackSearchParams{
|
|
Genre: &genre,
|
|
Page: 1,
|
|
Limit: 10,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, results, 2)
|
|
|
|
// ETHICAL ASSERTION: emerging artist's newer track must come first
|
|
// when using default sort (chronological), despite having 0 plays vs 5M
|
|
assert.Equal(t, "Jazz Improvisation", results[0].Title,
|
|
"emerging artist's track must rank first (chronological default), not star's track with 5M plays")
|
|
}
|