veza/veza-backend-api/internal/services/ethical_search_test.go
senke 0aa77d2bd9
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
feat(v0.12.9): ethical bias tests, discovery algorithm docs, CI coverage gates
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>
2026-03-12 08:19:41 +01:00

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")
}