Files
MusicServer/internal/testutil/fakedb/fakedb.go
T
Sansan 7b144ae419 Rewrite tests to run without a database
- Unit tests use an in-memory fake database (internal/testutil/fakedb),
  temp folders and httptest; `go test ./...` needs no Docker or env vars
- Integration tests (build tag "integration") start PostgreSQL with
  testcontainers: migrations step by step, SQL dumps (skipped if
  missing) and a full sync against a real database
- Removed tests that only checked copies of the logic or had empty
  bodies, the broken root integration_test.go, compose.test.yaml and
  .env.test with hardcoded paths
- testMusic MP3s replaced with tiny placeholders (sync never decodes
  audio), 194 MB -> 168 KB
2026-10-06 15:20:31 +02:00

328 lines
8.9 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 set.
func (s *Store) AddTestSong(song repository.Song) repository.Song {
defer s.lock()()
if song.Uuid == uuid.Nil {
song.Uuid = uuid.New()
}
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) AddSoundtrackPlayed(_ context.Context, id int32) error {
now := time.Now()
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.TimesPlayed++; st.LastPlayed = &now })
}
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) AddSongPlayed(_ context.Context, arg repository.AddSongPlayedParams) error {
defer s.lock()()
for i := range s.Songs {
if s.Songs[i].SoundtrackID == arg.SoundtrackID && s.Songs[i].SongName == arg.SongName {
s.Songs[i].TimesPlayed++
}
}
return s.Err
}
func (s *Store) RemoveBrokenSong(_ context.Context, arg repository.RemoveBrokenSongParams) error {
defer s.lock()()
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool {
return song.SoundtrackID == arg.SoundtrackID && song.Path == arg.Path
})
return s.Err
}
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
}