Files
MusicServer/internal/testutil/fakedb/fakedb.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

409 lines
11 KiB
Go

// Package fakedb provides an in-memory repository.Querier for tests.
package fakedb
import (
"cmp"
"context"
"math/rand/v2"
"slices"
"sync"
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Store is an in-memory implementation of the queries used by the application.
// Calling a query it does not implement panics through the nil embedded interface.
type Store struct {
repository.Querier
mu sync.Mutex
nextID int32
Soundtracks []repository.Soundtrack
Songs []repository.Song
Sessions map[string]repository.Session
// Err, when set, is returned by every implemented query.
Err error
}
// New returns an empty Store.
func New() *Store {
return &Store{Sessions: map[string]repository.Session{}}
}
func (s *Store) lock() func() {
s.mu.Lock()
return s.mu.Unlock
}
// AddSoundtrack inserts a soundtrack and returns it with its ID and UUID set.
func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack {
defer s.lock()()
s.nextID++
st.ID = s.nextID
if st.Uuid == uuid.Nil {
st.Uuid = uuid.New()
}
s.Soundtracks = append(s.Soundtracks, st)
return st
}
// AddTestSong inserts a song and returns it with its UUID and soundtrack UUID set.
func (s *Store) AddTestSong(song repository.Song) repository.Song {
defer s.lock()()
if song.Uuid == uuid.Nil {
song.Uuid = uuid.New()
}
if i := s.soundtrackIndex(song.SoundtrackID); song.SoundtrackUuid == uuid.Nil && i >= 0 {
song.SoundtrackUuid = s.Soundtracks[i].Uuid
}
s.Songs = append(s.Songs, song)
return song
}
// Soundtrack returns the soundtrack with the given name.
func (s *Store) Soundtrack(name string) (repository.Soundtrack, bool) {
defer s.lock()()
for _, st := range s.Soundtracks {
if st.SoundtrackName == name {
return st, true
}
}
return repository.Soundtrack{}, false
}
// SongsOf returns the songs of a soundtrack sorted by path.
func (s *Store) SongsOf(soundtrackID int32) []repository.Song {
defer s.lock()()
var songs []repository.Song
for _, song := range s.Songs {
if song.SoundtrackID == soundtrackID {
songs = append(songs, song)
}
}
slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.Path, b.Path) })
return songs
}
func (s *Store) soundtrackIndex(id int32) int {
return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.ID == id })
}
func (s *Store) updateSoundtrack(id int32, f func(*repository.Soundtrack)) error {
defer s.lock()()
if s.Err != nil {
return s.Err
}
if i := s.soundtrackIndex(id); i >= 0 {
f(&s.Soundtracks[i])
}
return nil
}
func sortedByName(soundtracks []repository.Soundtrack) []repository.Soundtrack {
slices.SortFunc(soundtracks, func(a, b repository.Soundtrack) int { return cmp.Compare(a.SoundtrackName, b.SoundtrackName) })
return soundtracks
}
func (s *Store) FindAllSoundtracks(context.Context) ([]repository.Soundtrack, error) {
defer s.lock()()
if s.Err != nil {
return nil, s.Err
}
var active []repository.Soundtrack
for _, st := range s.Soundtracks {
if st.Deleted == nil {
active = append(active, st)
}
}
return sortedByName(active), nil
}
func (s *Store) GetAllSoundtracksIncludingDeleted(context.Context) ([]repository.Soundtrack, error) {
defer s.lock()()
if s.Err != nil {
return nil, s.Err
}
return sortedByName(slices.Clone(s.Soundtracks)), nil
}
func (s *Store) InsertSoundtrack(_ context.Context, arg repository.InsertSoundtrackParams) (repository.InsertSoundtrackRow, error) {
if s.Err != nil {
return repository.InsertSoundtrackRow{}, s.Err
}
st := s.AddSoundtrack(repository.Soundtrack{
SoundtrackName: arg.SoundtrackName,
Path: arg.Path,
Hash: arg.Hash,
Uuid: arg.Uuid,
Added: time.Now(),
})
return repository.InsertSoundtrackRow{ID: st.ID, Uuid: st.Uuid}, nil
}
func (s *Store) ResetSoundtrackIdSeq(context.Context) (int64, error) {
return int64(s.nextID), s.Err
}
func (s *Store) UpdateSoundtrackHash(_ context.Context, arg repository.UpdateSoundtrackHashParams) error {
return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Hash = arg.Hash })
}
func (s *Store) UpdateSoundtrackName(_ context.Context, arg repository.UpdateSoundtrackNameParams) error {
return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.SoundtrackName, st.Path = arg.Name, arg.Path })
}
func (s *Store) SetSoundtrackFingerprint(_ context.Context, arg repository.SetSoundtrackFingerprintParams) error {
return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Fingerprint = arg.Fingerprint })
}
func (s *Store) RemoveSoundtrackDeletionDate(_ context.Context, id int32) error {
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.Deleted = nil })
}
func (s *Store) AddSoundtrackPlayedByUUID(_ context.Context, id uuid.UUID) error {
defer s.lock()()
now := time.Now()
for i := range s.Soundtracks {
if s.Soundtracks[i].Uuid == id {
s.Soundtracks[i].TimesPlayed++
s.Soundtracks[i].LastPlayed = &now
}
}
return s.Err
}
func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository.Soundtrack, error) {
defer s.lock()()
if s.Err != nil {
return repository.Soundtrack{}, s.Err
}
for _, st := range s.Soundtracks {
if st.Uuid == id && st.Deleted == nil {
return st, nil
}
}
return repository.Soundtrack{}, pgx.ErrNoRows
}
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
defer s.lock()()
if s.Err != nil {
return s.Err
}
now := time.Now()
for i := range s.Soundtracks {
if s.Soundtracks[i].Deleted == nil && !slices.Contains(ids, s.Soundtracks[i].ID) {
s.Soundtracks[i].Deleted = &now
}
}
return nil
}
func (s *Store) ClearSoundtracks(context.Context) error {
defer s.lock()()
s.Soundtracks = nil
return s.Err
}
func (s *Store) ClearSongs(context.Context) error {
defer s.lock()()
s.Songs = nil
return s.Err
}
func (s *Store) FindSongsFromSoundtrack(_ context.Context, soundtrackID int32) ([]repository.Song, error) {
if s.Err != nil {
return nil, s.Err
}
return s.SongsOf(soundtrackID), nil
}
func (s *Store) FetchAllSongs(context.Context) ([]repository.Song, error) {
defer s.lock()()
return slices.Clone(s.Songs), s.Err
}
func (s *Store) AddSong(_ context.Context, arg repository.AddSongParams) error {
if s.Err != nil {
return s.Err
}
s.AddTestSong(repository.Song{
SoundtrackID: arg.SoundtrackID,
SongName: arg.SongName,
Path: arg.Path,
FileName: arg.FileName,
Hash: arg.Hash,
Uuid: arg.Uuid,
SoundtrackUuid: arg.SoundtrackUuid,
})
return nil
}
func (s *Store) UpdateSongByUUID(_ context.Context, arg repository.UpdateSongByUUIDParams) error {
defer s.lock()()
for i := range s.Songs {
if s.Songs[i].Uuid == arg.Uuid {
s.Songs[i].SongName, s.Songs[i].FileName, s.Songs[i].Path, s.Songs[i].Hash = arg.SongName, arg.FileName, arg.Path, arg.Hash
}
}
return s.Err
}
func (s *Store) AddSongPlayedByUUID(_ context.Context, id uuid.UUID) error {
defer s.lock()()
for i := range s.Songs {
if s.Songs[i].Uuid == id {
s.Songs[i].TimesPlayed++
}
}
return s.Err
}
func (s *Store) GetSongsBySoundtrackUUID(_ context.Context, id uuid.UUID) ([]repository.Song, error) {
defer s.lock()()
if s.Err != nil {
return nil, s.Err
}
var songs []repository.Song
for _, song := range s.Songs {
if song.SoundtrackUuid == id {
songs = append(songs, song)
}
}
slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.SongName, b.SongName) })
return songs, nil
}
func (s *Store) GetActiveSongByUUID(_ context.Context, id uuid.UUID) (repository.GetActiveSongByUUIDRow, error) {
defer s.lock()()
if s.Err != nil {
return repository.GetActiveSongByUUIDRow{}, s.Err
}
for _, song := range s.Songs {
if song.Uuid != id {
continue
}
if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil {
st := s.Soundtracks[i]
return repository.GetActiveSongByUUIDRow{Song: song, SoundtrackName: st.SoundtrackName, SoundtrackPath: st.Path}, nil
}
}
return repository.GetActiveSongByUUIDRow{}, pgx.ErrNoRows
}
func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error {
defer s.lock()()
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool { return slices.Contains(uuids, song.Uuid) })
return s.Err
}
func (s *Store) GetRandomActiveSong(context.Context) (repository.GetRandomActiveSongRow, error) {
defer s.lock()()
if s.Err != nil {
return repository.GetRandomActiveSongRow{}, s.Err
}
var rows []repository.GetRandomActiveSongRow
for _, song := range s.Songs {
if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil {
rows = append(rows, repository.GetRandomActiveSongRow{Song: song, SoundtrackPath: s.Soundtracks[i].Path})
}
}
if len(rows) == 0 {
return repository.GetRandomActiveSongRow{}, pgx.ErrNoRows
}
return rows[rand.IntN(len(rows))], nil
}
func (s *Store) CreateSession(_ context.Context, arg repository.CreateSessionParams) (repository.Session, error) {
defer s.lock()()
if s.Err != nil {
return repository.Session{}, s.Err
}
session := repository.Session{
Token: arg.Token,
IpAddress: arg.IpAddress,
UserAgent: arg.UserAgent,
ClientType: arg.ClientType,
ExpiresAt: arg.ExpiresAt,
}
s.Sessions[arg.Token] = session
return session, nil
}
func (s *Store) GetSession(_ context.Context, token string) (repository.Session, error) {
defer s.lock()()
if s.Err != nil {
return repository.Session{}, s.Err
}
session, ok := s.Sessions[token]
if !ok {
return repository.Session{}, pgx.ErrNoRows
}
return session, nil
}
func (s *Store) DeleteSession(ctx context.Context, token string) error {
if err := ctx.Err(); err != nil {
return err
}
defer s.lock()()
delete(s.Sessions, token)
return s.Err
}
func (s *Store) DeleteExpiredSessions(context.Context) error {
defer s.lock()()
for token, session := range s.Sessions {
if session.ExpiresAt.Time.Before(time.Now()) {
delete(s.Sessions, token)
}
}
return s.Err
}
// HasSession reports whether a session with the given token exists.
func (s *Store) HasSession(token string) bool {
defer s.lock()()
_, ok := s.Sessions[token]
return ok
}
// Statistics queries return empty results; tests that need data use their own fakes.
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
return nil, s.Err
}
func (s *Store) GetLeastPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetLeastPlayedSoundtracksWithSongsRow, error) {
return nil, s.Err
}
func (s *Store) GetLastPlayedSoundtracks(context.Context, int32) ([]repository.GetLastPlayedSoundtracksRow, error) {
return nil, s.Err
}
func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository.GetOldestPlayedSoundtracksRow, error) {
return nil, s.Err
}
func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) {
return nil, s.Err
}
func (s *Store) GetMostPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetMostPlayedSongsWithSoundtrackRow, error) {
return nil, s.Err
}
func (s *Store) GetLeastPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetLeastPlayedSongsWithSoundtrackRow, error) {
return nil, s.Err
}
func (s *Store) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
return repository.GetStatisticsSummaryRow{}, s.Err
}