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>
This commit is contained in:
parent
fdf335bc4c
commit
0aa77d2bd9
6 changed files with 710 additions and 18 deletions
51
.github/workflows/backend-ci.yml
vendored
51
.github/workflows/backend-ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
32
.github/workflows/rust-ci.yml
vendored
32
.github/workflows/rust-ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
209
veza-backend-api/internal/core/discover/ethical_bias_test.go
Normal file
209
veza-backend-api/internal/core/discover/ethical_bias_test.go
Normal 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"])
|
||||
}
|
||||
268
veza-backend-api/internal/services/ethical_search_test.go
Normal file
268
veza-backend-api/internal/services/ethical_search_test.go
Normal 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")
|
||||
}
|
||||
131
veza-docs/DISCOVERY_ALGORITHM.md
Normal file
131
veza-docs/DISCOVERY_ALGORITHM.md
Normal 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.
|
||||
Loading…
Reference in a new issue