diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index e358f69e6..dbbc0b197 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -41,8 +41,30 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... - - name: Run unit tests - run: go test ./internal/handlers/... ./internal/services/... -short -coverprofile=coverage.out -covermode=atomic -timeout 5m + - name: Run unit tests with coverage + run: > + go test + ./internal/handlers/... + ./internal/services/... + ./internal/core/... + ./internal/middleware/... + -short -coverprofile=coverage.out -covermode=atomic -timeout 5m + + - name: Enforce coverage threshold (>= 70%) + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $NF}' | tr -d '%') + echo "Total coverage: ${COVERAGE}%" + if [ -z "$COVERAGE" ]; then + echo "::warning::Could not parse coverage percentage" + exit 0 + fi + # Compare as integers (remove decimal) + COV_INT=$(echo "$COVERAGE" | cut -d. -f1) + if [ "$COV_INT" -lt 70 ]; then + echo "::error::Coverage ${COVERAGE}% is below the 70% threshold" + exit 1 + fi + echo "::notice::Coverage ${COVERAGE}% meets the >= 70% threshold" - name: Upload coverage report uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 @@ -50,6 +72,30 @@ jobs: name: go-coverage path: veza-backend-api/coverage.out + - name: Generate coverage badge + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $NF}' | tr -d '%') + COV_INT=$(echo "$COVERAGE" | cut -d. -f1) + if [ "$COV_INT" -ge 80 ]; then + COLOR="brightgreen" + elif [ "$COV_INT" -ge 70 ]; then + COLOR="green" + elif [ "$COV_INT" -ge 50 ]; then + COLOR="yellow" + else + COLOR="red" + fi + echo "{\"schemaVersion\":1,\"label\":\"Go coverage\",\"message\":\"${COVERAGE}%\",\"color\":\"${COLOR}\"}" > coverage-badge.json + echo "Coverage badge: ${COVERAGE}% (${COLOR})" + + - name: Upload coverage badge + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: go-coverage-badge + path: veza-backend-api/coverage-badge.json + test-integration: runs-on: ubuntu-latest @@ -105,4 +151,3 @@ jobs: - name: Run integration tests run: go test -tags=integration ./internal/... -timeout 15m - diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index da2ec1066..1a34bea15 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -10,13 +10,43 @@ on: - 'veza-stream-server/**' jobs: - clippy-stream: + test-and-lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: components: clippy + - name: Clippy lint run: cargo clippy -- -D warnings working-directory: veza-stream-server + + - name: Run tests + run: cargo test --workspace --timeout 300 + working-directory: veza-stream-server + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Measure coverage + run: cargo tarpaulin --out json --output-dir target/coverage --timeout 300 --skip-clean + working-directory: veza-stream-server + + - name: Enforce coverage threshold (>= 50%) + run: | + COVERAGE=$(cat target/coverage/tarpaulin-report.json | python3 -c "import sys,json; print(f'{json.load(sys.stdin).get(\"coverage\", 0):.1f}')") + echo "Rust coverage: ${COVERAGE}%" + COV_INT=$(echo "$COVERAGE" | cut -d. -f1) + if [ "$COV_INT" -lt 50 ]; then + echo "::error::Rust coverage ${COVERAGE}% is below the 50% threshold" + exit 1 + fi + echo "::notice::Rust coverage ${COVERAGE}% meets the >= 50% threshold" + working-directory: veza-stream-server + + - name: Upload coverage report + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: rust-coverage + path: veza-stream-server/target/coverage/tarpaulin-report.json diff --git a/VEZA_VERSIONS_ROADMAP.md b/VEZA_VERSIONS_ROADMAP.md index 8c535feb5..dd792ccdb 100644 --- a/VEZA_VERSIONS_ROADMAP.md +++ b/VEZA_VERSIONS_ROADMAP.md @@ -1285,7 +1285,8 @@ Le diagnostic audit a identifié 9 modules "fantômes" — du code présent dans ### v0.12.9 — Tests Éthiques & Coverage CI -**Statut** : ⏳ TODO +**Statut** : ✅ DONE +**Complété le** : 2026-03-12 **Priorité** : P1 **Durée estimée** : 2-3 jours **Prerequisite** : v0.12.6.3 complète @@ -1295,21 +1296,29 @@ Les tests de biais éthiques exigés par les specs sont absents. La coverage n'e **Tâches** -- [ ] **TASK-ETH-001** : Test de biais artistes émergents (critère v0.10.1 non coché) - - La découverte ne doit pas défavoriser les artistes avec 0 ou peu de contenus -- [ ] **TASK-ETH-002** : Test recherche artiste 0 plays (critère v0.10.2 non coché) - - Un artiste avec 0 plays doit apparaître dans les résultats de recherche par nom -- [ ] **TASK-ETH-003** : Documenter l'algorithme de découverte +- [x] **TASK-ETH-001** : Test de biais artistes émergents (critère v0.10.1 non coché) + - 3 tests : `TestDiscovery_NoPlayCountBias_GenreBrowse`, `_TagBrowse`, `_EmergingArtistVisibility` + - Vérifie l'ordre chronologique (created_at DESC) dans GetTracksByGenre et GetTracksByTag + - Track 0 plays apparaît en premier si plus récent qu'un track avec 1M+ plays +- [x] **TASK-ETH-002** : Test recherche artiste 0 plays (critère v0.10.2 non coché) + - 4 tests : `TestSearch_ArtistZeroPlays_Discoverable`, `_ZeroPlaysTrack_NotFilteredOut`, `_DefaultSortIsChronological`, `_NoPopularityBias_InDefaultRanking` + - Vérifie qu'un artiste avec 0 plays est trouvable par nom + - Vérifie que le tri par défaut est chronologique, pas par popularité +- [x] **TASK-ETH-003** : Documenter l'algorithme de découverte - Fichier: `veza-docs/DISCOVERY_ALGORITHM.md` -- [ ] **TASK-COV-001** : Configurer coverage CI (Go + Rust) - - Quality gate: coverage >= 70% Go, >= 50% Rust -- [ ] **TASK-COV-002** : Rapport coverage global avec badge + - Documente les 6 mécanismes de découverte, les interdictions éthiques, et les tests de garantie +- [x] **TASK-COV-001** : Configurer coverage CI (Go + Rust) + - Go : ajout quality gate >= 70% dans backend-ci.yml (coverage étendue à core/ et middleware/) + - Rust : ajout cargo test + cargo-tarpaulin avec quality gate >= 50% dans rust-ci.yml +- [x] **TASK-COV-002** : Rapport coverage global avec badge + - Badge JSON généré sur push main (shields.io compatible) + - Artifacts uploadés : go-coverage, go-coverage-badge, rust-coverage **Critères d'acceptation** -- [ ] Test de biais artistes émergents PASSE -- [ ] Test recherche artiste 0 plays PASSE -- [ ] Algorithme de découverte documenté -- [ ] Coverage mesurée et enforcée en CI (>= 70% Go, >= 50% Rust) +- [x] Test de biais artistes émergents PASSE (4 tests discover, tous PASS) +- [x] Test recherche artiste 0 plays PASSE (4 tests search, tous PASS) +- [x] Algorithme de découverte documenté (`veza-docs/DISCOVERY_ALGORITHM.md`) +- [x] Coverage mesurée et enforcée en CI (>= 70% Go, >= 50% Rust) --- @@ -1577,7 +1586,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 : | v0.12.6.3 | Nettoyage Code Fantôme | P1 | ✅ DONE | 1-2j | v0.12.6 | | v0.12.7 | Internationalisation | P1 | ⏳ TODO | 3-4j | v0.12.5 | | v0.12.8 | Documentation & API Publique | P1 | ⏳ TODO | 3-4j | v0.12.6 | -| v0.12.9 | Tests Éthiques & Coverage CI | P1 | ⏳ TODO | 2-3j | v0.12.6.3 | +| v0.12.9 | Tests Éthiques & Coverage CI | P1 | ✅ DONE | 2-3j | v0.12.6.3 | | v0.13.0 | Conformité Features Partielles | P2 | ⏳ TODO | 5-7j | v0.12.9 | | v0.13.1 | Conformité Audio & Player | P2 | ⏳ TODO | 4-5j | v0.13.0 | | v0.13.2 | Consolidation Design System | P2 | ⏳ TODO | 2-3j | v0.13.0 | diff --git a/veza-backend-api/internal/core/discover/ethical_bias_test.go b/veza-backend-api/internal/core/discover/ethical_bias_test.go new file mode 100644 index 000000000..082356055 --- /dev/null +++ b/veza-backend-api/internal/core/discover/ethical_bias_test.go @@ -0,0 +1,209 @@ +package discover + +import ( + "context" + "encoding/json" + "testing" + "time" + + "veza-backend-api/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupTestDiscoverService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + err = db.AutoMigrate( + &models.User{}, + &models.Track{}, + &models.Genre{}, + &models.TrackGenre{}, + &models.Tag{}, + &models.TrackTag{}, + &models.UserGenreFollow{}, + &models.UserTagFollow{}, + ) + require.NoError(t, err) + + service := NewService(db, zap.NewNop()) + return service, db +} + +// createTestTrackWithPlays creates a track with explicit play_count and created_at. +func createTestTrackWithPlays(t *testing.T, db *gorm.DB, userID uuid.UUID, title string, playCount int64, createdAt time.Time) *models.Track { + t.Helper() + track := &models.Track{ + UserID: userID, + Title: title, + Artist: "Test Artist", + FilePath: "/test/" + title + ".mp3", + FileSize: 5 * 1024 * 1024, + Format: "MP3", + Duration: 180, + IsPublic: true, + Status: models.TrackStatusCompleted, + PlayCount: playCount, + CreatedAt: createdAt, + } + err := db.Create(track).Error + require.NoError(t, err) + return track +} + +// TestDiscovery_NoPlayCountBias_GenreBrowse verifies that GetTracksByGenre +// returns tracks in strict chronological order, regardless of play counts. +// TASK-ETH-001: ORIGIN_BUSINESS_LOGIC.md — discovery must NOT favor popular tracks. +func TestDiscovery_NoPlayCountBias_GenreBrowse(t *testing.T) { + service, db := setupTestDiscoverService(t) + ctx := context.Background() + + userID := uuid.New() + err := db.Create(&models.User{ID: userID, Username: "artist1", Email: "a1@test.com", IsActive: true}).Error + require.NoError(t, err) + + err = db.Create(&models.Genre{Slug: "electronic", Name: "Electronic"}).Error + require.NoError(t, err) + + now := time.Now() + + // Track A: emerging artist, 0 plays, uploaded MOST RECENTLY + trackA := createTestTrackWithPlays(t, db, userID, "Hidden Gem", 0, now) + err = db.Create(&models.TrackGenre{TrackID: trackA.ID, GenreSlug: "electronic", Position: 0}).Error + require.NoError(t, err) + + // Track B: viral hit, 1M plays, uploaded 1 hour ago + trackB := createTestTrackWithPlays(t, db, userID, "Viral Hit", 1_000_000, now.Add(-1*time.Hour)) + err = db.Create(&models.TrackGenre{TrackID: trackB.ID, GenreSlug: "electronic", Position: 0}).Error + require.NoError(t, err) + + // Track C: moderate track, 500 plays, uploaded 2 hours ago + trackC := createTestTrackWithPlays(t, db, userID, "Moderate Track", 500, now.Add(-2*time.Hour)) + err = db.Create(&models.TrackGenre{TrackID: trackC.ID, GenreSlug: "electronic", Position: 0}).Error + require.NoError(t, err) + + // Fetch discovery by genre + tracks, _, err := service.GetTracksByGenre(ctx, "electronic", 10, "") + require.NoError(t, err) + require.Len(t, tracks, 3) + + // ETHICAL ASSERTION: order must be chronological (newest first), + // NOT by play count. The 0-play track MUST appear first. + assert.Equal(t, trackA.ID, tracks[0].ID, "newest track (0 plays) must appear first — no play-count bias") + assert.Equal(t, trackB.ID, tracks[1].ID, "second newest (1M plays) must appear second") + assert.Equal(t, trackC.ID, tracks[2].ID, "oldest track must appear last regardless of play count") +} + +// TestDiscovery_NoPlayCountBias_TagBrowse verifies that GetTracksByTag +// returns tracks in strict chronological order, regardless of play counts. +// TASK-ETH-001: same principle applied to tag-based discovery. +func TestDiscovery_NoPlayCountBias_TagBrowse(t *testing.T) { + service, db := setupTestDiscoverService(t) + ctx := context.Background() + + userID := uuid.New() + err := db.Create(&models.User{ID: userID, Username: "artist2", Email: "a2@test.com", IsActive: true}).Error + require.NoError(t, err) + + tagID := uuid.New() + err = db.Create(&models.Tag{ID: tagID, Name: "ambient"}).Error + require.NoError(t, err) + + now := time.Now() + + // Track with 0 plays (newest) + trackNew := createTestTrackWithPlays(t, db, userID, "New Ambient", 0, now) + err = db.Create(&models.TrackTag{TrackID: trackNew.ID, TagID: tagID}).Error + require.NoError(t, err) + + // Track with 50000 plays (older) + trackOld := createTestTrackWithPlays(t, db, userID, "Popular Ambient", 50_000, now.Add(-3*time.Hour)) + err = db.Create(&models.TrackTag{TrackID: trackOld.ID, TagID: tagID}).Error + require.NoError(t, err) + + tracks, _, err := service.GetTracksByTag(ctx, "ambient", 10, "") + require.NoError(t, err) + require.Len(t, tracks, 2) + + // ETHICAL ASSERTION: chronological order, not popularity + assert.Equal(t, trackNew.ID, tracks[0].ID, "0-play track must appear first (newest)") + assert.Equal(t, trackOld.ID, tracks[1].ID, "50k-play track must appear second (older)") +} + +// TestDiscovery_EmergingArtistVisibility verifies that tracks from an artist +// with zero total plays appear alongside tracks from established artists. +// TASK-ETH-001: ORIGIN_FEATURES_REGISTRY.md F351-F355. +func TestDiscovery_EmergingArtistVisibility(t *testing.T) { + service, db := setupTestDiscoverService(t) + ctx := context.Background() + + // Emerging artist: 0 total plays across all tracks + emergingID := uuid.New() + err := db.Create(&models.User{ID: emergingID, Username: "emerging_artist", Email: "emerging@test.com", IsActive: true}).Error + require.NoError(t, err) + + // Established artist: many plays + establishedID := uuid.New() + err = db.Create(&models.User{ID: establishedID, Username: "established_star", Email: "star@test.com", IsActive: true}).Error + require.NoError(t, err) + + err = db.Create(&models.Genre{Slug: "jazz", Name: "Jazz"}).Error + require.NoError(t, err) + + now := time.Now() + + // Emerging artist's track (newest, 0 plays) + emergingTrack := createTestTrackWithPlays(t, db, emergingID, "First Song Ever", 0, now) + err = db.Create(&models.TrackGenre{TrackID: emergingTrack.ID, GenreSlug: "jazz", Position: 0}).Error + require.NoError(t, err) + + // Established artist's track (older, 2M plays) + starTrack := createTestTrackWithPlays(t, db, establishedID, "Greatest Hit", 2_000_000, now.Add(-30*time.Minute)) + err = db.Create(&models.TrackGenre{TrackID: starTrack.ID, GenreSlug: "jazz", Position: 0}).Error + require.NoError(t, err) + + tracks, _, err := service.GetTracksByGenre(ctx, "jazz", 10, "") + require.NoError(t, err) + require.Len(t, tracks, 2) + + // ETHICAL ASSERTION: emerging artist's track is NOT pushed down + assert.Equal(t, emergingTrack.ID, tracks[0].ID, "emerging artist's newer track must not be demoted by popularity") + assert.Equal(t, starTrack.ID, tracks[1].ID) +} + +// TestDiscovery_MetricsNotExposedInJSON verifies that play_count and like_count +// are not serialized in JSON responses (json:"-" tag). +// ORIGIN_UI_UX_SYSTEM.md §14.2: metrics are PRIVATE, visible only to creator. +func TestDiscovery_MetricsNotExposedInJSON(t *testing.T) { + track := models.Track{ + ID: uuid.New(), + Title: "Test Track", + Artist: "Test Artist", + PlayCount: 999999, + LikeCount: 50000, + } + + data, err := json.Marshal(track) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // play_count and like_count must NOT appear in JSON output + _, hasPlayCount := parsed["play_count"] + _, hasLikeCount := parsed["like_count"] + assert.False(t, hasPlayCount, "play_count must not be exposed in JSON (json:\"-\" tag required)") + assert.False(t, hasLikeCount, "like_count must not be exposed in JSON (json:\"-\" tag required)") + + // But title and artist should be present + assert.Equal(t, "Test Track", parsed["title"]) + assert.Equal(t, "Test Artist", parsed["artist"]) +} diff --git a/veza-backend-api/internal/services/ethical_search_test.go b/veza-backend-api/internal/services/ethical_search_test.go new file mode 100644 index 000000000..7fb3010d6 --- /dev/null +++ b/veza-backend-api/internal/services/ethical_search_test.go @@ -0,0 +1,268 @@ +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") +} diff --git a/veza-docs/DISCOVERY_ALGORITHM.md b/veza-docs/DISCOVERY_ALGORITHM.md new file mode 100644 index 000000000..84afbeb47 --- /dev/null +++ b/veza-docs/DISCOVERY_ALGORITHM.md @@ -0,0 +1,131 @@ +# Algorithme de Découverte Veza + +> **Version** : v0.12.9 +> **Dernière mise à jour** : 2026-03-12 +> **Référence** : ORIGIN_BUSINESS_LOGIC.md §11, ORIGIN_FEATURES_REGISTRY.md F351-F375, ORIGIN_UI_UX_SYSTEM.md §13-14 + +--- + +## Principes fondamentaux + +L'algorithme de découverte de Veza est conçu selon des principes éthiques stricts : + +1. **Chronologique, pas algorithmique** — Le feed et la découverte sont ordonnés par date de création (du plus récent au plus ancien). Aucun critère d'engagement (play count, likes, partages) n'influence l'ordre d'affichage. + +2. **Pas de ML/IA** — Aucun algorithme de machine learning (collaborative filtering, content-based filtering, analyse prédictive) n'est utilisé. La découverte repose exclusivement sur des mécanismes déclaratifs et humains. + +3. **Visibilité égale** — Un artiste émergent avec 0 écoutes a exactement la même chance d'apparaître qu'un artiste établi avec des millions d'écoutes, à condition qu'il soit dans le même genre/tag et que son contenu soit plus récent. + +4. **Métriques privées** — Les compteurs d'écoutes, likes et followers ne sont JAMAIS visibles publiquement. Ils sont réservés exclusivement au créateur dans son tableau de bord analytics. + +--- + +## Mécanismes de découverte + +### 1. Feed personnel (chronologique) + +**Implémentation** : `internal/core/feed/service.go` + +Le feed d'un utilisateur affiche les tracks des artistes qu'il suit, triées par `created_at DESC`. + +``` +Requête : SELECT tracks.* + FROM tracks + INNER JOIN follows ON follows.followed_id = tracks.creator_id + WHERE follows.follower_id = :user_id + ORDER BY tracks.created_at DESC, tracks.id DESC +``` + +Aucun facteur de popularité n'intervient. L'ordre est strictement chronologique. + +### 2. Découverte par genre (F353) + +**Implémentation** : `internal/core/discover/service.go` — `GetTracksByGenre()` + +Browse par genre avec pagination curseur. Tri : `created_at DESC, id DESC`. + +``` +Requête : SELECT tracks.* + FROM tracks + INNER JOIN track_genres ON track_genres.track_id = tracks.id + WHERE track_genres.genre_slug = :slug + AND tracks.status = 'completed' + AND tracks.is_public = true + ORDER BY tracks.created_at DESC, tracks.id DESC +``` + +### 3. Découverte par tag (F353) + +**Implémentation** : `internal/core/discover/service.go` — `GetTracksByTag()` + +Même principe que par genre, avec les tags déclarés par l'artiste. + +### 4. Nouveautés des genres suivis (F355) + +**Implémentation** : `internal/core/discover/service.go` — `GetTracksFromFollowedGenres()` + +Affiche les tracks récentes dans les genres que l'utilisateur suit. Tri chronologique. + +### 5. Playlists éditoriales (F141) + +**Implémentation** : `internal/core/discover/service.go` — `GetEditorialPlaylists()` + +Playlists créées manuellement par l'équipe de curation. Aucune génération automatique. + +### 6. Recherche textuelle (F375) + +**Implémentation** : +- PostgreSQL : `internal/services/search_service.go` (ILIKE pattern matching) +- Elasticsearch : `internal/elasticsearch/search_service.go` (BM25 + fuzziness) + +La recherche utilise la pertinence textuelle (BM25 sur Elasticsearch, ILIKE sur PostgreSQL). Le commentaire dans le code est explicite : + +```go +// F364: fuzziness for typo tolerance; no boost by popularity (ethical) +``` + +Les champs boostés sont `title^2` et `artist^2` — c'est un boost de pertinence textuelle, PAS de popularité. + +--- + +## Ce qui est explicitement INTERDIT + +| Mécanisme | Raison de l'interdiction | Référence | +|-----------|-------------------------|-----------| +| Collaborative filtering | Optimise la rétention, pas la découverte authentique | ORIGIN_BUSINESS_LOGIC.md §11 | +| Content-based filtering ML | Crée des bulles de filtrage | ORIGIN_BUSINESS_LOGIC.md §11 | +| Analyse prédictive du comportement | Manipulation de l'engagement | ORIGIN_BUSINESS_LOGIC.md §11 | +| Trending basé sur play counts | Favorise les artistes établis | ORIGIN_UI_UX_SYSTEM.md §13 | +| "X personnes écoutent maintenant" | Dark pattern FOMO | ORIGIN_UI_UX_SYSTEM.md §13 | +| Compteurs publics de followers | Biais de preuve sociale | ORIGIN_UI_UX_SYSTEM.md §13 | +| Leaderboards/classements | Gamification interdite | CLAUDE.md règle #3 | +| Badges de performance | XP/streaks interdits | CLAUDE.md règle #3 | + +--- + +## Garanties éthiques testées + +Les tests suivants vérifient en continu le respect de ces principes : + +| Test | Fichier | Ce qu'il vérifie | +|------|---------|-----------------| +| `TestDiscovery_NoPlayCountBias_GenreBrowse` | `discover/ethical_bias_test.go` | Genre browse : ordre chronologique, pas par play count | +| `TestDiscovery_NoPlayCountBias_TagBrowse` | `discover/ethical_bias_test.go` | Tag browse : même vérification | +| `TestDiscovery_EmergingArtistVisibility` | `discover/ethical_bias_test.go` | Artiste 0 plays non défavorisé | +| `TestDiscovery_MetricsNotExposedInJSON` | `discover/ethical_bias_test.go` | play_count/like_count absents du JSON | +| `TestSearch_ArtistZeroPlays_Discoverable` | `services/ethical_search_test.go` | Artiste 0 plays trouvable par nom | +| `TestSearch_ZeroPlaysTrack_NotFilteredOut` | `services/ethical_search_test.go` | Track 0 plays non filtré | +| `TestSearch_DefaultSortIsChronological` | `services/ethical_search_test.go` | Tri par défaut = chronologique | +| `TestSearch_NoPopularityBias_InDefaultRanking` | `services/ethical_search_test.go` | Pas de biais popularité dans le ranking par défaut | + +--- + +## Audit et transparence + +Cet algorithme est : +- **Documenté** : ce fichier constitue la documentation publique +- **Auditable** : le code source est lisible et les requêtes SQL sont explicites +- **Testé** : les tests éthiques ci-dessus sont exécutés en CI +- **Déterministe** : pas de composante aléatoire ou opaque + +Toute modification de l'algorithme de découverte doit passer les tests éthiques existants et être documentée ici.