Files
pipu-music/services/api/internal/dbtest/integration_test.go
T
mrleo1nidandClaude Opus 5.5 3da82e902a
ci / api / api (push) Successful in 29s
ci / db / db (push) Successful in 47s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
release / release (версия и заметки) (push) Successful in 4s
release / android (APK в релиз) (push) Successful in 3m20s
fix(library): survive a music disk that is not mounted
On the test server the host music folder was not mounted, so every stream and
fingerprint failed with no such file, and a scan of the empty folder would have
marked the whole library missing and purged it after 30 days.

- a scan of an empty source folder with known files aborts with a clear error
- fingerprinting skips such a source with one warning instead of one per file
- LIBRARY_HOST mounts use rslave, so a disk mounted after start shows up
- update.sh refuses to deploy with an empty LIBRARY_HOST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:52 +03:00

239 lines
10 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"
"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"))
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)
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.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 WHERE id = $1`, fileID)
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)
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})
// Импорт: связь с файлом библиотеки, место освобождено.
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusPendingReview}, uploads.StatusApproved, nil))
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting, nil))
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusImporting}, uploads.StatusImported,
func(tx pgx.Tx) error { return uploads.MarkImported(ctx, tx, fileID, fileIDs[1]) }))
used, _, err = up.Usage(ctx, author)
require.NoError(t, err)
assert.EqualValues(t, 0, used)
}