378 lines
14 KiB
Go
378 lines
14 KiB
Go
//go:build integration
|
||
|
||
package main
|
||
|
||
// Сквозная проверка основных функций пользователя на той же сборке, что в продакшене (newApp), поверх
|
||
// настоящих PostgreSQL + pgvector, Redis и Meilisearch (CI-job `ci / db`): первый вход администратора,
|
||
// подключение папки с музыкой и скан, поиск, альбом и исполнитель, стрим, события и история, лайки,
|
||
// «Моя волна» (запасная, без рекомендателя), состояние плеера, очередь на добавление до рассмотрения.
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/redis/go-redis/v9"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"pipu/api/internal/api/gen"
|
||
"pipu/api/internal/library"
|
||
"pipu/api/internal/library/meta"
|
||
"pipu/api/internal/platform/config"
|
||
"pipu/api/internal/platform/db"
|
||
)
|
||
|
||
type fakeProber struct{}
|
||
|
||
// Probe: «Исполнитель - Название.flac» в папке альбома, 200 с.
|
||
func (fakeProber) Probe(_ context.Context, abs string) (library.Probe, error) {
|
||
base := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
|
||
artist, title, _ := strings.Cut(base, " - ")
|
||
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: filepath.Base(filepath.Dir(abs)), Year: "1988"},
|
||
}, nil
|
||
}
|
||
|
||
type client struct {
|
||
t *testing.T
|
||
base string
|
||
token string
|
||
}
|
||
|
||
// call — JSON-запрос; out == nil — тело не нужно. Возвращает статус.
|
||
func (c *client) call(method, path string, body, out any) int {
|
||
c.t.Helper()
|
||
var rd io.Reader
|
||
if body != nil {
|
||
raw, err := json.Marshal(body)
|
||
require.NoError(c.t, err)
|
||
rd = bytes.NewReader(raw)
|
||
}
|
||
req, err := http.NewRequest(method, c.base+path, rd)
|
||
require.NoError(c.t, err)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if c.token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||
}
|
||
res, err := http.DefaultClient.Do(req)
|
||
require.NoError(c.t, err)
|
||
defer func() { _ = res.Body.Close() }()
|
||
raw, _ := io.ReadAll(res.Body)
|
||
if res.StatusCode >= 400 {
|
||
c.t.Logf("%s %s → %d %s", method, path, res.StatusCode, raw)
|
||
}
|
||
if out != nil && res.StatusCode < 300 && len(raw) > 0 {
|
||
require.NoError(c.t, json.Unmarshal(raw, out), string(raw))
|
||
}
|
||
return res.StatusCode
|
||
}
|
||
|
||
func (c *client) ok(method, path string, body, out any) {
|
||
c.t.Helper()
|
||
st := c.call(method, path, body, out)
|
||
require.Less(c.t, st, 300, "%s %s → %d", method, path, st)
|
||
}
|
||
|
||
// eventually повторяет проверку до таймаута (скан, индексация поиска — фоновые).
|
||
func eventually(t *testing.T, what string, fn func() bool) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(60 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if fn() {
|
||
return
|
||
}
|
||
time.Sleep(300 * time.Millisecond)
|
||
}
|
||
t.Fatalf("timeout: %s", what)
|
||
}
|
||
|
||
type track struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
StreamURL string `json:"stream_url"`
|
||
Available bool `json:"available"`
|
||
Reaction string `json:"reaction"`
|
||
Album struct {
|
||
ID string `json:"id"`
|
||
} `json:"album"`
|
||
Artists []struct {
|
||
ID string `json:"id"`
|
||
} `json:"artists"`
|
||
}
|
||
|
||
func TestUserJourney(t *testing.T) {
|
||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||
if dbURL == "" {
|
||
t.Skip("TEST_DATABASE_URL is not set")
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
host := t.TempDir()
|
||
album := filepath.Join(host, "music", "Кино", "Группа крови")
|
||
require.NoError(t, os.MkdirAll(album, 0o755))
|
||
for _, name := range []string{"Кино - Группа крови.flac", "Кино - Закрой за мной дверь.flac", "Кино - Война.flac"} {
|
||
require.NoError(t, os.WriteFile(filepath.Join(album, name), []byte("audio:"+name), 0o644))
|
||
}
|
||
|
||
t.Setenv("DATABASE_URL", dbURL)
|
||
t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL"))
|
||
t.Setenv("BOOTSTRAP_ADMIN_PASSWORD", "admin")
|
||
cfg, err := config.Load[config.API]()
|
||
require.NoError(t, err)
|
||
cfg.LibraryHostRoot = host
|
||
cfg.LibraryRoot = t.TempDir()
|
||
cfg.CacheDir = t.TempDir()
|
||
cfg.UploadsDir = t.TempDir()
|
||
cfg.UpdatesDir = t.TempDir()
|
||
cfg.XAccel = false
|
||
cfg.CookieSecure = false
|
||
cfg.RecommenderURL = "http://127.0.0.1:1" // рекомендатель недоступен — волна запасная
|
||
cfg.StorageAgentURL = "http://127.0.0.1:1"
|
||
if v := os.Getenv("TEST_MEILI_URL"); v != "" {
|
||
cfg.MeiliURL = v
|
||
}
|
||
cfg.MeiliMasterKey = os.Getenv("TEST_MEILI_KEY")
|
||
|
||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||
require.NoError(t, err)
|
||
defer pool.Close()
|
||
require.NoError(t, db.Migrate(ctx, pool))
|
||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||
require.NoError(t, err)
|
||
rdb := redis.NewClient(opts)
|
||
defer func() { _ = rdb.Close() }()
|
||
|
||
// Предупреждения и ошибки сервиса — в вывод теста: по ним видно, почему шаг не прошёл.
|
||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
handler, err := newApp(ctx, cfg, pool, rdb, logger, fakeProber{})
|
||
require.NoError(t, err)
|
||
srv := httptest.NewServer(handler)
|
||
defer srv.Close()
|
||
c := &client{t: t, base: srv.URL}
|
||
|
||
// 1. Первый вход администратора: стандартные данные → обязательная смена.
|
||
var info struct {
|
||
BootstrapRequired bool `json:"bootstrap_required"`
|
||
}
|
||
c.ok("GET", "/api/v1/server-info", nil, &info)
|
||
var tokens struct {
|
||
AccessToken string `json:"access_token"`
|
||
User struct {
|
||
ID string `json:"id"`
|
||
MustChangeCredentials bool `json:"must_change_credentials"`
|
||
} `json:"user"`
|
||
}
|
||
c.ok("POST", "/api/v1/auth/login", map[string]any{"username": "admin", "password": "admin", "client_type": "web"}, &tokens)
|
||
c.token = tokens.AccessToken
|
||
if tokens.User.MustChangeCredentials {
|
||
c.ok("POST", "/api/v1/me/bootstrap-credentials",
|
||
map[string]any{"new_username": "owner", "new_password": "Str0ng-Passw0rd!"}, &tokens)
|
||
c.token = tokens.AccessToken
|
||
}
|
||
|
||
// 2. Папка с музыкой и скан.
|
||
var src struct {
|
||
ID string `json:"id"`
|
||
Status string `json:"status"`
|
||
}
|
||
c.ok("POST", "/api/v1/admin/sources", map[string]any{"name": "Диск", "type": "local", "local_path": "music"}, &src)
|
||
require.Equal(t, "mounted", src.Status)
|
||
c.ok("POST", "/api/v1/admin/sources/"+src.ID+"/scan", map[string]any{"full": true}, nil)
|
||
var status struct {
|
||
Tracks int `json:"tracks"`
|
||
}
|
||
eventually(t, "scan", func() bool {
|
||
c.ok("GET", "/api/v1/admin/library/status", nil, &status)
|
||
return status.Tracks == 3
|
||
})
|
||
|
||
// 3. Поиск (Meilisearch индексирует в фоне).
|
||
var found struct {
|
||
Artists []struct {
|
||
ID string `json:"id"`
|
||
} `json:"artists"`
|
||
Albums []struct {
|
||
ID string `json:"id"`
|
||
} `json:"albums"`
|
||
Tracks []track `json:"tracks"`
|
||
}
|
||
eventually(t, "search index", func() bool {
|
||
time.Sleep(time.Second)
|
||
return c.call("GET", "/api/v1/search?q="+url.QueryEscape("кино"), nil, &found) == 200 && len(found.Albums) == 1
|
||
})
|
||
|
||
// 4. Альбом, исполнитель, популярные треки.
|
||
var alb struct {
|
||
Tracks []track `json:"tracks"`
|
||
}
|
||
c.ok("GET", "/api/v1/albums/"+found.Albums[0].ID, nil, &alb)
|
||
require.Len(t, alb.Tracks, 3)
|
||
tr := alb.Tracks[0]
|
||
require.True(t, tr.Available)
|
||
artistID := tr.Artists[0].ID
|
||
c.ok("GET", "/api/v1/artists/"+artistID, nil, nil)
|
||
c.ok("GET", "/api/v1/artists/"+artistID+"/albums", nil, nil)
|
||
var top struct {
|
||
Items []track `json:"items"`
|
||
}
|
||
c.ok("GET", "/api/v1/artists/"+artistID+"/top-tracks", nil, &top)
|
||
require.Len(t, top.Items, 3)
|
||
|
||
// 5. Стрим по подписанной ссылке (без заголовка авторизации, как у <audio>).
|
||
res, err := http.Get(srv.URL + tr.StreamURL + "&q=original")
|
||
require.NoError(t, err)
|
||
body, _ := io.ReadAll(res.Body)
|
||
_ = res.Body.Close()
|
||
require.Equal(t, 200, res.StatusCode)
|
||
require.True(t, strings.HasPrefix(string(body), "audio:"), "streams the file")
|
||
|
||
// 6. События прослушивания → история.
|
||
now := time.Now().UTC()
|
||
ev := func(kind string, at time.Time, extra map[string]any) map[string]any {
|
||
e := map[string]any{"id": uuid.Must(uuid.NewV7()).String(), "type": kind, "track_id": tr.ID,
|
||
"occurred_at": at.Format(time.RFC3339Nano), "context_type": "album", "context_id": tr.Album.ID}
|
||
for k, v := range extra {
|
||
e[k] = v
|
||
}
|
||
return e
|
||
}
|
||
c.ok("POST", "/api/v1/events", map[string]any{"events": []any{
|
||
ev("play_start", now.Add(-200*time.Second), map[string]any{"position_ms": 0}),
|
||
ev("play_end", now, map[string]any{"position_ms": 200_000, "played_ms": 200_000}),
|
||
}}, nil)
|
||
var hist struct {
|
||
Items []struct {
|
||
Track track `json:"track"`
|
||
} `json:"items"`
|
||
}
|
||
c.ok("GET", "/api/v1/me/history", nil, &hist)
|
||
require.NotEmpty(t, hist.Items)
|
||
|
||
// 7. Лайк и коллекция.
|
||
c.ok("PUT", "/api/v1/me/reactions/tracks/"+tr.ID, map[string]any{"reaction": "like"}, nil)
|
||
var likes struct {
|
||
Total int `json:"total"`
|
||
}
|
||
c.ok("GET", "/api/v1/me/likes", nil, &likes)
|
||
require.Equal(t, 1, likes.Total)
|
||
|
||
// Персональные подсказки: лайкнутый трек — первым среди треков альбома.
|
||
var sug struct {
|
||
Tracks []track `json:"tracks"`
|
||
}
|
||
c.ok("GET", "/api/v1/search/suggest?q="+url.QueryEscape("кино"), nil, &sug)
|
||
require.NotEmpty(t, sug.Tracks)
|
||
require.Equal(t, tr.ID, sug.Tracks[0].ID)
|
||
|
||
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
|
||
var onb struct {
|
||
Choice *gen.OnboardingChoice `json:"choice"`
|
||
Required bool `json:"required"`
|
||
Artists []struct {
|
||
ID string `json:"id"`
|
||
} `json:"artists"`
|
||
}
|
||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||
require.NotEmpty(t, onb.Artists)
|
||
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}, "gender": "male", "age_group": "13_19", "age_group_year": 1900}, nil)
|
||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||
require.False(t, onb.Required)
|
||
|
||
require.NotNil(t, onb.Choice)
|
||
require.Equal(t, gen.OnboardingChoiceGenderMale, *onb.Choice.Gender)
|
||
require.Equal(t, gen.N1319, *onb.Choice.AgeGroup)
|
||
require.Greater(t, *onb.Choice.AgeGroupYear, 1900, "client cannot forge the selection year")
|
||
_, err = pool.Exec(ctx, `UPDATE user_profiles_rec SET onboarding = jsonb_set(onboarding,
|
||
'{age_group_year}', to_jsonb(EXTRACT(YEAR FROM CURRENT_DATE)::int - 20)) WHERE user_id = $1`, tokens.User.ID)
|
||
require.NoError(t, err)
|
||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||
require.Equal(t, gen.N3039, *onb.Choice.AgeGroup, "13–19 twenty years ago is approximately 30–39 now")
|
||
originalYear := *onb.Choice.AgeGroupYear
|
||
c.ok("PUT", "/api/v1/me/onboarding", onb.Choice, nil)
|
||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||
require.Equal(t, originalYear, *onb.Choice.AgeGroupYear, "editing tastes preserves the age anchor")
|
||
require.Equal(t, gen.N3039, *onb.Choice.AgeGroup)
|
||
c.ok("POST", "/api/v1/admin/users/"+tokens.User.ID+"/reset-recommendations", nil, nil)
|
||
onb.Choice = nil
|
||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||
require.True(t, onb.Required)
|
||
require.Nil(t, onb.Choice, "all onboarding answers are cleared")
|
||
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{}, "genres": []string{}}, nil)
|
||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||
require.False(t, onb.Required, "skip is remembered")
|
||
|
||
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
|
||
var wave struct {
|
||
SessionID string `json:"session_id"`
|
||
Tracks []track `json:"tracks"`
|
||
Fallback bool `json:"fallback"`
|
||
}
|
||
c.ok("GET", "/api/v1/wave/options", nil, nil)
|
||
c.ok("POST", "/api/v1/wave/sessions", map[string]any{"activity": nil, "mood": nil}, &wave)
|
||
require.NotEmpty(t, wave.Tracks, "wave has tracks")
|
||
require.True(t, wave.Fallback)
|
||
c.ok("POST", "/api/v1/wave/sessions/"+wave.SessionID+"/next", map[string]any{"feedback": []any{}}, nil)
|
||
|
||
// 9. Состояние плеера (продолжить на другом устройстве).
|
||
c.ok("PUT", "/api/v1/me/playback-state", map[string]any{
|
||
"track_id": tr.ID, "position_ms": 1000, "queue": []string{tr.ID}, "queue_index": 0,
|
||
}, nil)
|
||
var st struct {
|
||
TrackID string `json:"track_id"`
|
||
}
|
||
c.ok("GET", "/api/v1/me/playback-state", nil, &st)
|
||
require.Equal(t, tr.ID, st.TrackID)
|
||
|
||
// 10. Очередь на добавление: резерв → tus → обработка до рассмотрения.
|
||
upload := []byte("audio:new upload")
|
||
sum := sha256.Sum256(upload)
|
||
var batch struct {
|
||
Files []struct {
|
||
TusURL *string `json:"tus_url"`
|
||
Rejected *string `json:"rejected"`
|
||
} `json:"files"`
|
||
}
|
||
c.ok("GET", "/api/v1/uploads/quota", nil, nil)
|
||
c.ok("POST", "/api/v1/uploads", map[string]any{"files": []any{map[string]any{
|
||
"client_id": "1", "name": "Кино - Кукушка.flac", "rel_path": "Кино/Чёрный альбом/Кино - Кукушка.flac",
|
||
"size": len(upload), "sha256": hex.EncodeToString(sum[:]),
|
||
}}}, &batch)
|
||
require.Nil(t, batch.Files[0].Rejected)
|
||
req, err := http.NewRequest("PATCH", srv.URL+*batch.Files[0].TusURL, bytes.NewReader(upload))
|
||
require.NoError(t, err)
|
||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||
req.Header.Set("Tus-Resumable", "1.0.0")
|
||
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
||
req.Header.Set("Upload-Offset", "0")
|
||
res, err = http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
_ = res.Body.Close()
|
||
require.Equal(t, 204, res.StatusCode)
|
||
var mine struct {
|
||
Items []struct {
|
||
Status string `json:"status"`
|
||
} `json:"items"`
|
||
}
|
||
eventually(t, "upload processed", func() bool {
|
||
c.ok("GET", "/api/v1/uploads/mine", nil, &mine)
|
||
return len(mine.Items) == 1 && mine.Items[0].Status == "pending_review"
|
||
})
|
||
fmt.Println("user journey ok")
|
||
}
|