feat(test): add cache tests

This commit is contained in:
Phan An 2022-07-25 15:25:27 +02:00
parent 9aa50d1963
commit 2fae65bb91
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
8 changed files with 110 additions and 49 deletions

View file

@ -0,0 +1,57 @@
import { expect, it, vi } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { Cache } from '@/services/cache'
let cache: Cache
new class extends UnitTestCase {
protected beforeEach () {
super.beforeEach(() => cache = new Cache())
}
protected afterEach () {
super.afterEach(() => vi.useRealTimers())
}
protected test () {
it('sets and gets a value', () => {
cache.set('foo', 'bar')
expect(cache.get('foo')).toBe('bar')
})
it('invalidates an entry after set time', () => {
vi.useFakeTimers()
cache.set('foo', 'bar', 999)
expect(cache.has('foo')).toBe(true)
vi.advanceTimersByTime(1000 * 1000)
expect(cache.has('foo')).toBe(false)
})
it('removes an entry', () => {
cache.set('foo', 'bar')
cache.remove('foo')
expect(cache.get('foo')).toBeUndefined()
})
it('checks an entry\'s presence', () => {
cache.set('foo', 'bar')
expect(cache.hit('foo')).toBe(true)
expect(cache.has('foo')).toBe(true)
expect(cache.miss('foo')).toBe(false)
cache.remove('foo')
expect(cache.hit('foo')).toBe(false)
expect(cache.has('foo')).toBe(false)
expect(cache.miss('foo')).toBe(true)
})
it('remembers a value', async () => {
const resolver = vi.fn().mockResolvedValue('bar')
expect(cache.has('foo')).toBe(false)
expect(await cache.remember('foo', resolver)).toBe('bar')
expect(cache.get('foo')).toBe('bar')
})
}
}

View file

@ -1,54 +1,58 @@
const CACHE_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day
const DEFAULT_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day
export const Cache = {
storage: new Map<any, {
time: number,
export class Cache {
private storage = new Map<any, {
expires: number,
value: any
}>(),
}>()
normalizeKey: (key: any) => typeof key === 'object' ? JSON.stringify(key) : key,
private static normalizeKey (key: any) {
return typeof key === 'object' ? JSON.stringify(key) : key
}
has (key: any) {
return this.hit(this.normalizeKey(key))
},
public has (key: any) {
return this.hit(Cache.normalizeKey(key))
}
get<T> (key: any) {
return this.storage.get(this.normalizeKey(key))!.value as T
},
public get<T> (key: any) {
return this.storage.get(Cache.normalizeKey(key))?.value as T
}
set (key: any, value: any) {
this.storage.set(this.normalizeKey(key), {
public set (key: any, value: any, seconds: number = DEFAULT_EXPIRATION_TIME) {
this.storage.set(Cache.normalizeKey(key), {
value,
time: Date.now()
expires: Date.now() + seconds * 1000
})
},
}
hit (key: any) {
return !this.miss(this.normalizeKey(key))
},
public hit (key: any) {
return !this.miss(Cache.normalizeKey(key))
}
miss (key: any) {
key = this.normalizeKey(key)
public miss (key: any) {
key = Cache.normalizeKey(key)
if (!this.storage.has(key)) return true
const { time } = this.storage.get(key)!
const { expires } = this.storage.get(key)!
if (time < Date.now() - CACHE_EXPIRATION_TIME) {
if (expires < Date.now()) {
this.storage.delete(key)
return true
}
return false
},
}
invalidate (key: any) {
this.storage.delete(this.normalizeKey(key))
},
public remove (key: any) {
this.storage.delete(Cache.normalizeKey(key))
}
async resolve<T> (key: any, fetcher: Closure) {
key = this.normalizeKey(key)
async remember<T> (key: any, resolver: Closure, seconds: number = DEFAULT_EXPIRATION_TIME) {
key = Cache.normalizeKey(key)
this.hit(key) || this.set(key, await fetcher())
this.hit(key) || this.set(key, await resolver(), seconds)
return this.get<T>(key)
}
}
export const cache = new Cache()

View file

@ -1,13 +1,13 @@
import { Cache, httpService } from '@/services'
import { cache, httpService } from '@/services'
import { albumStore, artistStore, songStore } from '@/stores'
export const mediaInfoService = {
async fetchForArtist (artist: Artist) {
const cacheKey = ['artist.info', artist.id]
if (Cache.has(cacheKey)) return Cache.get<ArtistInfo>(cacheKey)
if (cache.has(cacheKey)) return cache.get<ArtistInfo>(cacheKey)
const info = await httpService.get<ArtistInfo | null>(`artists/${artist.id}/information`)
info && Cache.set(cacheKey, info)
info && cache.set(cacheKey, info)
if (info?.image) {
artistStore.byId(artist.id)!.image = info.image
@ -18,10 +18,10 @@ export const mediaInfoService = {
async fetchForAlbum (album: Album) {
const cacheKey = ['album.info', album.id]
if (Cache.has(cacheKey)) return Cache.get<AlbumInfo>(cacheKey)
if (cache.has(cacheKey)) return cache.get<AlbumInfo>(cacheKey)
const info = await httpService.get<AlbumInfo | null>(`albums/${album.id}/information`)
info && Cache.set(cacheKey, info)
info && cache.set(cacheKey, info)
if (info?.cover) {
albumStore.byId(album.id)!.cover = info.cover

View file

@ -1,4 +1,4 @@
import { Cache, httpService } from '@/services'
import { cache, httpService } from '@/services'
import { eventBus } from '@/utils'
import router from '@/router'
@ -9,7 +9,7 @@ interface YouTubeSearchResult {
export const youTubeService = {
searchVideosBySong: async (song: Song, nextPageToken: string) => {
return await Cache.resolve<YouTubeSearchResult>(
return await cache.remember<YouTubeSearchResult>(
['youtube.search', song.id, nextPageToken],
async () => await httpService.get<YouTubeSearchResult>(
`youtube/search/song/${song.id}?pageToken=${nextPageToken}`

View file

@ -1,6 +1,6 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
import { Cache, httpService } from '@/services'
import { cache, httpService } from '@/services'
import { arrayify, logger } from '@/utils'
import { songStore } from '@/stores'
@ -66,7 +66,7 @@ export const albumStore = {
if (!album) {
try {
album = this.syncWithVault(
await Cache.resolve<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
await cache.remember<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
)[0]
} catch (e) {
logger.error(e)

View file

@ -1,6 +1,6 @@
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, orderBy, take, unionBy } from 'lodash'
import { Cache, httpService } from '@/services'
import { cache, httpService } from '@/services'
import { arrayify, logger } from '@/utils'
const UNKNOWN_ARTIST_ID = 1
@ -59,7 +59,7 @@ export const artistStore = {
if (!artist) {
try {
artist = this.syncWithVault(
await Cache.resolve<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
await cache.remember<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
)[0]
} catch (e) {
logger.error(e)

View file

@ -1,7 +1,7 @@
import { differenceBy, orderBy } from 'lodash'
import { reactive } from 'vue'
import { logger } from '@/utils'
import { Cache, httpService } from '@/services'
import { cache, httpService } from '@/services'
import models from '@/config/smart-playlist/models'
import operators from '@/config/smart-playlist/operators'
@ -61,7 +61,7 @@ export const playlistStore = {
}
await httpService.post(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
Cache.invalidate(['playlist.songs', playlist.id])
cache.remove(['playlist.songs', playlist.id])
return playlist
},
@ -72,7 +72,7 @@ export const playlistStore = {
}
await httpService.delete(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
Cache.invalidate(['playlist.songs', playlist.id])
cache.remove(['playlist.songs', playlist.id])
return playlist
},
@ -83,7 +83,7 @@ export const playlistStore = {
rules: this.serializeSmartPlaylistRulesForStorage(data.rules || [])
})
playlist.is_smart && Cache.invalidate(['playlist.songs', playlist.id])
playlist.is_smart && cache.remove(['playlist.songs', playlist.id])
Object.assign(playlist, data)
return playlist

View file

@ -3,7 +3,7 @@ import slugify from 'slugify'
import { merge, orderBy, sumBy, take, unionBy } from 'lodash'
import { reactive, UnwrapNestedRefs, watch } from 'vue'
import { arrayify, eventBus, logger, secondsToHis, use } from '@/utils'
import { authService, Cache, httpService } from '@/services'
import { authService, cache, httpService } from '@/services'
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore } from '@/stores'
export type SongUpdateData = {
@ -155,21 +155,21 @@ export const songStore = {
},
async fetchForAlbum (album: Album) {
return await Cache.resolve<Song[]>(
return await cache.remember<Song[]>(
[`album.songs`, album.id],
async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${album.id}/songs`))
)
},
async fetchForArtist (artist: Artist) {
return await Cache.resolve<Song[]>(
return await cache.remember<Song[]>(
['artist.songs', artist.id],
async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${artist.id}/songs`))
)
},
async fetchForPlaylist (playlist: Playlist) {
return await Cache.resolve<Song[]>(
return await cache.remember<Song[]>(
[`playlist.songs`, playlist.id],
async () => this.syncWithVault(await httpService.get<Song[]>(`playlists/${playlist.id}/songs`))
)