Files
pipu-music/services/api/internal/dbtest/integration_test.go
T
mrleo1nid 64dd8ed05e
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
2026-10-29 10:28:29 +03:00

317 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}