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
317 lines
13 KiB
Go
317 lines
13 KiB
Go
//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)
|
||
|
||
}
|