//go:build integration // Package dbtest — интеграционные проверки SQL на настоящем PostgreSQL с pgvector (CI-job `ci / db`). // Локально: TEST_DATABASE_URL=postgres://… go test -tags integration ./internal/dbtest/ package dbtest import ( "context" "crypto/sha256" "database/sql" "log/slog" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "pipu/api/internal/library" "pipu/api/internal/library/meta" "pipu/api/internal/platform/db" "pipu/api/internal/uploads" "pipu/api/migrations" ) func connect(t *testing.T) *pgxpool.Pool { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := db.Connect(ctx, url) require.NoError(t, err) t.Cleanup(pool.Close) return pool } // Все миграции применяются, откатываются до нуля и применяются снова — Down-секции рабочие. func TestMigrationsUpDownUp(t *testing.T) { pool := connect(t) ctx := context.Background() require.NoError(t, db.Migrate(ctx, pool)) sqlDB := stdlib.OpenDBFromPool(pool) defer func(d *sql.DB) { _ = d.Close() }(sqlDB) provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS) require.NoError(t, err) _, err = provider.DownTo(ctx, 0) require.NoError(t, err, "down migrations") _, err = provider.Up(ctx) require.NoError(t, err, "up again") } // fakeProber — теги из имени файла «Исполнитель - Название.flac», длительность 200 с. type fakeProber struct{} func (fakeProber) Probe(_ context.Context, abs string) (library.Probe, error) { base := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) artist, title, _ := strings.Cut(base, " - ") album := filepath.Base(filepath.Dir(abs)) return library.Probe{ Format: "flac", Codec: "flac", Bitrate: 900_000, SampleRate: 44100, BitDepth: 16, Channels: 2, DurationMs: 200_000, Tags: meta.Fields{Artist: artist, Title: title, Album: album, Year: "1988", Track: "1"}, }, nil } func seedUser(t *testing.T, pool *pgxpool.Pool, name, role string) uuid.UUID { id := uuid.Must(uuid.NewV7()) _, err := pool.Exec(context.Background(), `INSERT INTO users (id, username, password_hash, role) VALUES ($1, $2, 'x', $3)`, id, name+"-"+id.String()[:8], role) require.NoError(t, err) return id } // Сканер, каталог, отпечатки, шаблоны записи и очередь на добавление — запросы на настоящей схеме. func TestLibraryAndUploads(t *testing.T) { pool := connect(t) ctx := context.Background() require.NoError(t, db.Migrate(ctx, pool)) log := slog.New(slog.DiscardHandler) lib := library.NewStore(pool) // --- Хранилище и точечный скан --- root := t.TempDir() album := filepath.Join(root, "Кино", "Группа крови") require.NoError(t, os.MkdirAll(album, 0o755)) for _, name := range []string{"Кино - Группа крови.flac", "Кино - Закрой за мной дверь.flac"} { require.NoError(t, os.WriteFile(filepath.Join(album, name), []byte(name), 0o644)) } tpl := "{albumartist}/{album}/{title}.{ext}" src, err := lib.SaveSource(ctx, library.Source{ ID: uuid.Must(uuid.NewV7()), Name: "it", Type: library.SourceLocal, TagsFirst: true, Status: "mounted", NFSOptions: "ro", AcceptsUploads: true, WriteTemplate: &tpl, }) require.NoError(t, err) assert.True(t, src.AcceptsUploads) require.NotNil(t, src.WriteTemplate) board := library.NewProgressBoard() scanner := library.NewScanner(lib, fakeProber{}, board, log) var fileIDs []uuid.UUID for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} { id, err := scanner.ScanFile(ctx, src, root, rel) require.NoError(t, err) fileIDs = append(fileIDs, id) } again, err := scanner.ScanFile(ctx, src, root, "Кино/Группа крови/Кино - Группа крови.flac") require.NoError(t, err) assert.Equal(t, fileIDs[0], again, "rescan keeps the file row") // Пустой каталог при известных файлах (диск не смонтирован) — скан прерывается и ничего не помечает пропавшим. require.NoError(t, scanner.Start(src, t.TempDir(), false)) require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 50*time.Millisecond) p, _ := board.Get(src.ID) assert.Contains(t, p.Error, "is the disk mounted") var missing int require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM media_files WHERE source_id = $1 AND missing_since IS NOT NULL`, src.ID).Scan(&missing)) assert.Zero(t, missing) // --- Отпечатки --- todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10) require.NoError(t, err) require.Len(t, todo, 2) sum := sha256.Sum256([]byte("Кино - Группа крови.flac")) fp := &library.Fingerprint{DurationMs: 200_000, Points: []uint32{1, 2, 3, 4294967295}} require.NoError(t, lib.SaveFingerprint(ctx, todo[0].ID, sum[:], fp, true)) todo, err = lib.FilesToFingerprint(ctx, true, uuid.Nil, 10) require.NoError(t, err) assert.Len(t, todo, 1) // --- Шаблон записи и популярные треки --- samples, err := lib.TemplateSamples(ctx, src.ID, 50) require.NoError(t, err) assert.Len(t, samples, 2) var artistID uuid.UUID require.NoError(t, pool.QueryRow(ctx, `SELECT id FROM artists WHERE name = 'Кино'`).Scan(&artistID)) top, err := lib.ArtistTopTracks(ctx, artistID, 10) require.NoError(t, err) assert.Len(t, top, 2) // --- Очередь на добавление --- up := uploads.NewStore(pool) author := seedUser(t, pool, "moder", "moderator") settings, err := up.Settings(ctx) require.NoError(t, err) settings.QueueLimitBytes = 1000 require.NoError(t, up.SaveSettings(ctx, settings)) settings, err = up.Settings(ctx) require.NoError(t, err) assert.EqualValues(t, 1000, settings.QueueLimitBytes) fresh := sha256.Sum256([]byte("new file")) coverPath := "covers/album.jpg" batch, res, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{ {ClientID: "a", Name: "a.flac", Size: 400, SHA256: fresh[:]}, {ClientID: "dup", Name: "b.flac", Size: 100, SHA256: sum[:]}, // уже в библиотеке {ClientID: "big", Name: "c.flac", Size: 900, SHA256: []byte("cccccccccccccccccccccccccccccccc")}, }, settings, &coverPath) require.NoError(t, err) assert.NotEqual(t, uuid.Nil, batch) byClient := map[string]uploads.Reserved{} for _, r := range res { byClient[r.ClientID] = r } assert.Empty(t, byClient["a"].Rejected) assert.Equal(t, uploads.CodeDuplicate, byClient["dup"].Rejected) assert.Equal(t, uploads.CodeQueueFull, byClient["big"].Rejected) used, userUsed, err := up.Usage(ctx, author) require.NoError(t, err) assert.EqualValues(t, 400, used) assert.EqualValues(t, 400, userUsed) fileID := byClient["a"].FileID require.NoError(t, up.SetReceived(ctx, fileID, 400)) require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusUploading}, uploads.StatusProcessing, nil)) next, err := up.NextWithStatus(ctx, uploads.StatusProcessing) require.NoError(t, err) assert.Equal(t, fileID, next.ID) require.NotNil(t, next.CoverPath) assert.Equal(t, coverPath, *next.CoverPath) coverInUse, err := up.StagingInUse(ctx) require.NoError(t, err) assert.True(t, coverInUse[coverPath], "active batch covers must survive staging reconciliation") require.ErrorIs(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusUploading}, uploads.StatusExpired, nil), uploads.ErrWrongStatus) // Обработка: метаданные, отпечаток, путь; затем отклонение освобождает квоту. require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview, func(tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE upload_files SET metadata = '{"artist":"Кино","album":"Группа крови","disc":2}', fingerprint = '{1,2,3}', duration_ms = 200000, target_source_id = $2 WHERE id = $1`, fileID, src.ID) return err })) f, err := up.File(ctx, fileID) require.NoError(t, err) assert.Equal(t, uploads.StatusPendingReview, f.Status) require.NotNil(t, f.Meta) assert.Equal(t, "Кино", f.Meta.Artist) require.NotNil(t, f.TargetSourceName) assert.Equal(t, src.Name, *f.TargetSourceName) files, nextCursor, err := up.List(ctx, uploads.ListFilter{AuthorID: &author, Statuses: []uploads.Status{uploads.StatusPendingReview}, Limit: 1}) require.NoError(t, err) assert.Len(t, files, 1) assert.Empty(t, nextCursor) _, dup, err := up.DuplicateBySHA(ctx, sum[:], uuid.Nil) require.NoError(t, err) assert.True(t, dup) cands, err := up.FingerprintCandidates(ctx, 200_000, 5000, uuid.Nil) require.NoError(t, err) assert.Len(t, cands, 2, "library print and the queued one") match, err := up.MetadataMatch(ctx, meta.Key("Кино"), meta.Key("Группа крови"), 200_000, 2000) require.NoError(t, err) assert.NotNil(t, match) ar, al, _, err := up.MatchCatalog(ctx, meta.Key("Кино"), meta.Key("Группа крови")) require.NoError(t, err) assert.NotNil(t, ar) assert.NotNil(t, al) discs, err := up.BatchMaxDisc(ctx, batch, "Группа крови") require.NoError(t, err) assert.Equal(t, 2, discs) inUse, err := up.StagingInUse(ctx) require.NoError(t, err) assert.True(t, inUse[f.StagingPath]) stale, err := up.Stale(ctx, time.Now().Add(time.Hour)) require.NoError(t, err) assert.Empty(t, stale, "only uploading files expire") up.Audit(ctx, &author, "upload.test", fileID, map[string]any{"ok": true}) // A failure writing reviewer metadata must roll back both status and quota. require.Error(t, up.Decide(ctx, fileID, uuid.New(), false, "invalid reviewer")) f, err = up.File(ctx, fileID) require.NoError(t, err) assert.Equal(t, uploads.StatusPendingReview, f.Status) assert.Nil(t, f.ReviewedBy) used, _, err = up.Usage(ctx, author) require.NoError(t, err) assert.EqualValues(t, 400, used) // Import operations retain the status/reason/link and quota invariants. require.NoError(t, up.Decide(ctx, fileID, author, true, "discard on approval")) f, err = up.File(ctx, fileID) require.NoError(t, err) require.NotNil(t, f.ReviewedBy) assert.Equal(t, author, *f.ReviewedBy) assert.Nil(t, f.StatusReason) require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting)) require.NoError(t, up.FailImport(ctx, fileID, "retryable failure")) f, err = up.File(ctx, fileID) require.NoError(t, err) assert.Equal(t, uploads.StatusImportFailed, f.Status) require.NotNil(t, f.StatusReason) assert.Equal(t, "retryable failure", *f.StatusReason) require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusImportFailed}, uploads.StatusApproved)) require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting)) require.NoError(t, up.CompleteImport(ctx, fileID, src.ID, "Кино - Группа крови.flac", fileIDs[1])) require.ErrorIs(t, up.CompleteImport(ctx, fileID, src.ID, "ignored.flac", fileIDs[1]), uploads.ErrWrongStatus) unusedCover, err := up.UnusedBatchCover(ctx, batch) require.NoError(t, err) require.NotNil(t, unusedCover) assert.Equal(t, coverPath, *unusedCover) used, _, err = up.Usage(ctx, author) require.NoError(t, err) assert.EqualValues(t, 0, used) // Two reviewers cannot both decide the same pending file or release quota twice. secondHash := sha256.Sum256([]byte("concurrent decision")) _, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{ {ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]}, }, settings, nil) require.NoError(t, err) require.Len(t, reserved, 1) raceID := reserved[0].FileID require.NotEqual(t, uuid.Nil, raceID) require.NoError(t, up.ChangeStatus(ctx, raceID, []uploads.Status{uploads.StatusUploading}, uploads.StatusProcessing)) require.NoError(t, up.ChangeStatus(ctx, raceID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview)) var wg sync.WaitGroup results := make(chan error, 2) for _, reason := range []string{"first", "second"} { wg.Add(1) go func(reason string) { defer wg.Done(); results <- up.Decide(ctx, raceID, author, false, reason) }(reason) } wg.Wait() close(results) success, conflicts := 0, 0 for err := range results { if err == nil { success++ } else { require.ErrorIs(t, err, uploads.ErrWrongStatus) conflicts++ } } assert.Equal(t, 1, success) assert.Equal(t, 1, conflicts) used, _, err = up.Usage(ctx, author) require.NoError(t, err) assert.Zero(t, used) f, err = up.File(ctx, raceID) require.NoError(t, err) assert.Equal(t, uploads.StatusRejected, f.Status) require.NotNil(t, f.ReviewedBy) assert.Equal(t, author, *f.ReviewedBy) require.NotNil(t, f.StatusReason) assert.Contains(t, []string{"first", "second"}, *f.StatusReason) }