// 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 }