Files
pipu-music/services/api/cmd/api/e2e_test.go
T
mrleo1nid 582c0dc3d4
ci / db / db (push) Successful in 57s
ci / api / api (push) Successful in 58s
fix(ci): align onboarding assertion with generated enum
2026-10-23 12:03:23 +03:00

378 lines
14 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 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")
}