Merge branch 'feat/v0.12.9-tests-ethiques-coverage'

This commit is contained in:
senke 2026-03-12 08:20:00 +01:00
commit f47434ea06
6 changed files with 710 additions and 18 deletions

View file

@ -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

View file

@ -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

View file

@ -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 |

View file

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

View file

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

View file

@ -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.