Merge remote-tracking branch 'origin/develop' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2025-03-13 13:00:21 +02:00
commit 155d9b0999
12 changed files with 663 additions and 145 deletions

View file

@ -0,0 +1 @@
Internal: Migrate OAuth store to pinia

View file

@ -1,3 +1,4 @@
/* global process */
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
@ -16,6 +17,7 @@ import { applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import { useOAuthStore } from 'src/stores/oauth'
import { useI18nStore } from 'src/stores/i18n'
import { useInterfaceStore } from 'src/stores/interface'
import { useAnnouncementsStore } from 'src/stores/announcements'
@ -227,8 +229,9 @@ const getStickers = async ({ store }) => {
}
const getAppSecret = async ({ store }) => {
if (store.state.oauth.userToken) {
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
const oauth = useOAuthStore()
if (oauth.userToken) {
store.commit('setBackendInteractor', backendInteractorService(oauth.getToken))
}
}
@ -322,20 +325,65 @@ const setConfig = async ({ store }) => {
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
getAppSecret({ store })
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
if (store.getters.getUserToken()) {
return store.dispatch('loginUser', store.getters.getUserToken())
const oauth = useOAuthStore()
if (oauth.getUserToken) {
return store.dispatch('loginUser', oauth.getUserToken)
}
return Promise.resolve()
}
const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
const app = createApp(App)
// Must have app use pinia before we do anything that touches the store
// https://pinia.vuejs.org/core-concepts/plugins.html#Introduction
// "Plugins are only applied to stores created after the plugins themselves, and after pinia is passed to the app, otherwise they won't be applied."
app.use(pinia)
const waitForAllStoresToLoad = async () => {
// the stores that do not persist technically do not need to be awaited here,
// but that involves either hard-coding the stores in some place (prone to errors)
// or writing another vite plugin to analyze which stores needs persisting (++load time)
const allStores = import.meta.glob('../stores/*.js', { eager: true })
if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors
if (!Object.keys(allStores).length) {
throw new Error('No stores are available. Check the code in src/boot/after_store.js')
}
}
await Promise.all(
Object.entries(allStores)
.map(async ([name, mod]) => {
const isStoreName = name => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error('Each store file must export exactly one store as a named export. Check your code in src/stores/')
}
}
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`)
}
await p
} else {
throw new Error(`Store module ${name} does not export a 'use...' function`)
}
}))
}
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
storageError = e
}
if (storageError) {
useInterfaceStore().pushGlobalNotice({ messageKey: 'errors.storage_unavailable', level: 'error' })
}

View file

@ -1,5 +1,7 @@
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import oauthApi from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -17,11 +19,11 @@ const LoginForm = {
computed: {
isPasswordAuth () { return this.requiredPassword },
isTokenAuth () { return this.requiredToken },
...mapStores(useOAuthStore),
...mapState({
registrationOpen: state => state.instance.registrationOpen,
instance: state => state.instance,
loggingIn: state => state.users.loggingIn,
oauth: state => state.oauth
}),
...mapGetters(
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
@ -41,37 +43,30 @@ const LoginForm = {
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.$store.dispatch('ensureAppToken')
this.oauthStore.ensureAppToken()
.then(() => {
const app = {
clientId: this.oauth.clientId,
clientSecret: this.oauth.clientSecret,
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.login({ ...app, ...data })
})
},
submitPassword () {
const { clientId } = this.oauth
const data = {
clientId,
oauth: this.oauth,
instance: this.instance.server,
commit: this.$store.commit
}
this.error = false
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.$store.dispatch('ensureAppToken').then(() => {
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauth.clientId,
clientSecret: this.oauth.clientSecret,
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.getTokenWithCredentials(
{
...app,
instance: data.instance,
instance: this.instance.server,
username: this.user.username,
password: this.user.password
}

View file

@ -1,10 +1,12 @@
import oauth from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
const oac = {
props: ['code'],
mounted () {
if (this.code) {
const { clientId, clientSecret } = this.$store.state.oauth
const oauthStore = useOAuthStore()
const { clientId, clientSecret } = oauthStore
oauth.getToken({
clientId,
@ -12,7 +14,7 @@ const oac = {
instance: this.$store.state.instance.server,
code: this.code
}).then((result) => {
this.$store.commit('setToken', result.access_token)
oauthStore.setToken(result.access_token)
this.$store.dispatch('loginUser', result.access_token)
this.$router.push({ name: 'friends' })
})

View file

@ -94,3 +94,160 @@ export default function createPersistedState ({
}
})
}
/**
* This persists state for pinia, which falls back to read from the vuex state
* if pinia persisted state does not exist.
*
* When you migrate a module from vuex to pinia, you have to keep the original name.
* If the module was called `xxx`, the name of the store has to be `xxx` too.
*
* This adds one property to the store, $persistLoaded, which is a promise
* that resolves when the initial state is loaded. If the plugin is not enabled,
* $persistLoaded is a promise that resolves immediately.
* If we are not able to get the stored state because storage.getItem() throws or
* rejects, $persistLoaded will be a rejected promise with the thrown error.
*
* Call signature:
*
* defineStore(name, {
* ...,
* // setting the `persist` property enables this plugin
* // IMPORTANT: by default it is disabled, you have to set `persist` to at least an empty object
* persist: {
* // set to list of individual paths, or undefined/unset to persist everything
* paths: [],
* // function to call after loading initial state
* // if afterLoad is a function, it must return a state object that will be sent to `store.$patch`, or a promise to the state object
* // by default afterLoad is undefined
* afterLoad: (originalState) => {
* // ...
* return modifiedState
* },
* // if it exists, only persist state after these actions
* // if it doesn't exist or is undefined, persist state after every mutation of the state
* saveImmediatelyActions: [],
* // what to do after successfully saving the state
* onSaveSuccess: () => {},
* // what to do after there is an error saving the state
* onSaveError: () => {}
* }
* })
*
*/
export const piniaPersistPlugin = ({
vuexKey = 'vuex-lz',
keyFunction = (id) => `pinia-local-${id}`,
storage = defaultStorage,
reducer = defaultReducer
} = {}) => ({ store, options }) => {
if (!options.persist) {
return {
$persistLoaded: Promise.resolve()
}
}
let resolveLoaded
let rejectLoaded
const loadedPromise = new Promise((resolve, reject) => {
resolveLoaded = resolve
rejectLoaded = reject
})
const {
afterLoad,
paths = [],
saveImmediatelyActions,
onSaveSuccess = () => {},
onSaveError = () => {}
} = options.persist || {}
const loadedGuard = { loaded: false }
const key = keyFunction(store.$id)
const getState = async () => {
const id = store.$id
const value = await storage.getItem(key)
if (value) {
return value
}
const fallbackValue = await storage.getItem(vuexKey)
if (fallbackValue && fallbackValue[id]) {
console.info(`Migrating ${id} store data from vuex to pinia`)
const res = fallbackValue[id]
await storage.setItem(key, res)
return res
}
return {}
}
const setState = (state) => {
if (!loadedGuard.loaded) {
console.info('waiting for old state to be loaded...')
return Promise.reject()
} else {
return storage.setItem(key, state)
}
}
const getMaybeAugmentedState = async () => {
const savedRawState = await getState()
if (typeof afterLoad === 'function') {
try {
return await afterLoad(savedRawState)
} catch (e) {
console.error('Error running afterLoad:', e)
return savedRawState
}
} else {
return savedRawState
}
}
const persistCurrentState = async (state) => {
const stateClone = cloneDeep(state)
const stateToPersist = reducer(stateClone, paths)
try {
const res = await setState(stateToPersist)
onSaveSuccess(res)
} catch (e) {
console.error('Cannot persist state:', e)
onSaveError(e)
}
}
getMaybeAugmentedState()
.then(savedState => {
if (savedState) {
store.$patch(savedState)
}
loadedGuard.loaded = true
resolveLoaded()
// only subscribe after we have done setting the initial state
if (!saveImmediatelyActions) {
store.$subscribe(async (_mutation, state) => {
await persistCurrentState(state)
})
} else {
store.$onAction(({
name,
store,
after,
}) => {
if (saveImmediatelyActions.includes(name)) {
after(() => persistCurrentState(store.$state))
}
})
}
}, error => {
console.error('Cannot load storage:', error)
rejectLoaded(error)
})
return {
$persistLoaded: loadedPromise
}
}

View file

@ -16,7 +16,7 @@ import vuexModules from './modules/index.js'
import { createI18n } from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
import createPersistedState, { piniaPersistPlugin } from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.js'
import messages from './i18n/messages.js'
@ -71,6 +71,8 @@ const persistedStateOptions = {
let storageError
const plugins = [pushNotifications]
const pinia = createPinia()
pinia.use(piniaPersistPlugin())
try {
const persistedState = await createPersistedState(persistedStateOptions)
plugins.push(persistedState)

View file

@ -1,3 +1,5 @@
import { useOAuthStore } from 'src/stores/oauth.js'
const PASSWORD_STRATEGY = 'password'
const TOKEN_STRATEGY = 'token'
@ -68,8 +70,8 @@ const mutations = {
// actions
const actions = {
async login ({ state, dispatch, commit }, { access_token: accessToken }) {
commit('setToken', accessToken, { root: true })
async login ({ state, dispatch }, { access_token: accessToken }) {
useOAuthStore().setToken(accessToken)
await dispatch('loginUser', accessToken, { root: true })
resetState(state)
}

View file

@ -7,7 +7,6 @@ import config from './config.js'
import profileConfig from './profileConfig.js'
import serverSideStorage from './serverSideStorage.js'
import adminSettings from './adminSettings.js'
import oauth from './oauth.js'
import authFlow from './auth_flow.js'
import oauthTokens from './oauth_tokens.js'
import drafts from './drafts.js'
@ -23,7 +22,6 @@ export default {
profileConfig,
serverSideStorage,
adminSettings,
oauth,
authFlow,
oauthTokens,
drafts,

View file

@ -5,6 +5,7 @@ import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { registerPushNotifications, unregisterPushNotifications } from '../services/sw/sw.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
@ -526,17 +527,18 @@ const users = {
})
},
async signUp (store, userInfo) {
const oauthStore = useOAuthStore()
store.commit('signUpPending')
try {
const token = await store.dispatch('ensureAppToken')
const token = await oauthStore.ensureAppToken()
const data = await apiService.register(
{ credentials: token, params: { ...userInfo } }
)
if (data.access_token) {
store.commit('signUpSuccess')
store.commit('setToken', data.access_token)
oauthStore.setToken(data.access_token)
store.dispatch('loginUser', data.access_token)
return 'ok'
} else { // Request succeeded, but user cannot login yet.
@ -554,21 +556,16 @@ const users = {
},
logout (store) {
const { oauth, instance } = store.rootState
const data = {
...oauth,
commit: store.commit,
instance: instance.server
}
const oauth = useOAuthStore()
const { instance } = store.rootState
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return store.dispatch('ensureApp')
return oauth.ensureApp()
.then((app) => {
const params = {
app,
instance: data.instance,
instance: instance.server,
token: oauth.userToken
}
@ -577,9 +574,9 @@ const users = {
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.commit('clearToken')
oauth.clearToken()
store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.commit('setBackendInteractor', backendInteractorService(oauth.getToken))
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingLists')
store.dispatch('stopFetchingBookmarkFolders')
@ -674,7 +671,7 @@ const users = {
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(response.status)) {
commit('clearToken')
useOAuthStore().clearToken()
}
if (response.status === 401) {

View file

@ -1,3 +1,4 @@
import { defineStore } from 'pinia'
import { createApp, getClientToken, verifyAppToken } from 'src/services/new_api/oauth.js'
// status codes about verifyAppToken (GET /api/v1/apps/verify_credentials)
@ -20,7 +21,7 @@ const isClientDataRejected = error => (
error.statusCode === 400
)
const oauth = {
export const useOAuthStore = defineStore('oauth', {
state: () => ({
clientId: false,
clientSecret: false,
@ -34,82 +35,77 @@ const oauth = {
*/
userToken: false
}),
mutations: {
setClientData (state, { clientId, clientSecret }) {
state.clientId = clientId
state.clientSecret = clientSecret
},
setAppToken (state, token) {
state.appToken = token
},
setToken (state, token) {
state.userToken = token
},
clearToken (state) {
state.userToken = false
// state.token is userToken with older name, coming from persistent state
// let's clear it as well, since it is being used as a fallback of state.userToken
delete state.token
}
},
getters: {
getToken: state => () => {
// state.token is userToken with older name, coming from persistent state
// added here for smoother transition, otherwise user will be logged out
return state.userToken || state.token || state.appToken
getToken () {
return this.userToken || this.appToken
},
getUserToken: state => () => {
// state.token is userToken with older name, coming from persistent state
// added here for smoother transition, otherwise user will be logged out
return state.userToken || state.token
getUserToken () {
return this.userToken
}
},
actions: {
async createApp ({ rootState, commit }) {
const instance = rootState.instance.server
setClientData ({ clientId, clientSecret }) {
this.clientId = clientId
this.clientSecret = clientSecret
},
setAppToken (token) {
this.appToken = token
},
setToken (token) {
this.userToken = token
},
clearToken () {
this.userToken = false
},
async createApp () {
const { state } = window.vuex
const instance = state.instance.server
const app = await createApp(instance)
commit('setClientData', app)
this.setClientData(app)
return app
},
/// Use this if you want to get the client id and secret but are not interested
/// in whether they are valid.
/// @return {{ clientId: string, clientSecret: string }} An object representing the app
ensureApp ({ state, dispatch }) {
if (state.clientId && state.clientSecret) {
async ensureApp () {
if (this.clientId && this.clientSecret) {
return {
clientId: state.clientId,
clientSecret: state.clientSecret
clientId: this.clientId,
clientSecret: this.clientSecret
}
} else {
return dispatch('createApp')
return this.createApp()
}
},
async getAppToken ({ state, rootState, commit }) {
async getAppToken () {
const { state } = window.vuex
const instance = state.instance.server
const res = await getClientToken({
clientId: state.clientId,
clientSecret: state.clientSecret,
instance: rootState.instance.server
clientId: this.clientId,
clientSecret: this.clientSecret,
instance
})
commit('setAppToken', res.access_token)
this.setAppToken(res.access_token)
return res.access_token
},
/// Use this if you want to ensure the app is still valid to use.
/// @return {string} The access token to the app (not attached to any user)
async ensureAppToken ({ state, rootState, dispatch, commit }) {
if (state.appToken) {
async ensureAppToken () {
const { state } = window.vuex
if (this.appToken) {
try {
await verifyAppToken({
instance: rootState.instance.server,
appToken: state.appToken
instance: state.instance.server,
appToken: this.appToken
})
return state.appToken
return this.appToken
} catch (e) {
if (!isAppTokenRejected(e)) {
// The server did not reject our token, but we encountered other problems. Maybe the server is down.
throw e
} else {
// The app token is rejected, so it is no longer useful.
commit('setAppToken', false)
this.setAppToken(false)
}
}
}
@ -117,7 +113,7 @@ const oauth = {
// appToken is not available, or is rejected: try to get a new one.
// First, make sure the client id and client secret are filled.
try {
await dispatch('ensureApp')
await this.ensureApp()
} catch (e) {
console.error('Cannot create app', e)
throw e
@ -126,7 +122,7 @@ const oauth = {
// Note that at this step, the client id and secret may be invalid
// (because the backend may have already deleted the app due to no user login)
try {
return await dispatch('getAppToken')
return await this.getAppToken()
} catch (e) {
if (!isClientDataRejected(e)) {
// Non-credentials problem, fail fast
@ -135,17 +131,27 @@ const oauth = {
} else {
// the client id and secret are invalid, so we should clear them
// and re-create our app
commit('setClientData', {
this.setClientData({
clientId: false,
clientSecret: false
})
await dispatch('createApp')
await this.createApp()
// try once again to get the token
return await dispatch('getAppToken')
return await this.getAppToken()
}
}
}
},
persist: {
afterLoad (state) {
// state.token is userToken with older name, coming from persistent state
if (state.token && !state.userToken) {
state.userToken = state.token
}
if ('token' in state) {
delete state.token
}
return state
}
}
}
export default oauth
})

View file

@ -0,0 +1,307 @@
import { setActivePinia, createPinia, defineStore } from 'pinia'
import { createApp } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { piniaPersistPlugin } from 'src/lib/persisted_state.js'
const app = createApp({})
const getMockStorage = () => {
let state = {}
return {
getItem: vi.fn(async key => {
console.log('get:', key, state[key])
return state[key]
}),
setItem: vi.fn(async (key, value) => {
console.log('set:', key, value)
state[key] = value
}),
_clear: () => {
state = {}
}
}
}
let mockStorage
beforeEach(() => {
mockStorage = getMockStorage()
const pinia = createPinia().use(piniaPersistPlugin({ storage: mockStorage }))
app.use(pinia)
setActivePinia(pinia)
})
describe('piniaPersistPlugin', () => {
describe('initial state', () => {
test('it does not load anything if it is not enabled', async () => {
await mockStorage.setItem('pinia-local-test', { a: 3 })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 })
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(1)
expect(test.b).to.eql(2)
})
test('$persistLoaded rejects if getItem() throws', async () => {
const error = new Error('unable to get storage')
mockStorage.getItem = vi.fn(async () => {
throw error
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {}
})
const test = useTestStore()
await expect(test.$persistLoaded).rejects.toThrowError(error)
})
test('it loads from pinia storage', async () => {
await mockStorage.setItem('pinia-local-test', { a: 3, c: { d: 0 } })
await mockStorage.setItem('vuex-lz', { test: { a: 4 } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {}
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(3)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
test('it loads from vuex storage as fallback', async () => {
await mockStorage.setItem('vuex-lz', { test: { a: 4, c: { d: 0 } } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {}
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(4)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
test('it loads from vuex storage and writes it into pinia storage', async () => {
await mockStorage.setItem('vuex-lz', { test: { a: 4, c: { d: 0 } } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {
afterLoad (state) {
return {
...state,
a: 5
}
}
}
})
const test = useTestStore()
await test.$persistLoaded
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 4, c: { d: 0 } })
expect(test.a).to.eql(5)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
test('it does not modify state if there is nothing to load', async () => {
await mockStorage.setItem('vuex-lz', { test2: { a: 4 } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {}
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(1)
expect(test.b).to.eql(2)
})
})
describe('paths', () => {
test('it saves everything if paths is unspecified', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {}
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 3, b: 2 })
})
test('it saves only specified paths', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {
paths: ['a', 'c.d']
}
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 3, c: { d: 4 } })
})
})
test('it only saves after load', async () => {
const onSaveError = vi.fn()
const onSaveSuccess = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError
}
})
const test = useTestStore()
test.$patch({ a: 3 })
expect(await mockStorage.getItem('pinia-local-test')).to.eql(undefined)
// NOTE: it should not even have tried to save, because the subscribe function
// is called only after loading the initial state.
expect(mockStorage.setItem).not.toHaveBeenCalled()
// this asserts that it has not called setState() in persistCurrentState()
expect(onSaveError).not.toHaveBeenCalled()
expect(onSaveSuccess).not.toHaveBeenCalled()
await test.$persistLoaded
test.$patch({ a: 4 })
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 4, b: 2 })
})
describe('saveImmediatelyActions', () => {
test('it should only persist state after specified actions', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
actions: {
increaseA () {
++this.a
},
increaseB () {
++this.b
}
},
persist: {
saveImmediatelyActions: ['increaseA']
}
})
const test = useTestStore()
await test.$persistLoaded
await test.increaseA()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 2, b: 2 })
await test.increaseB()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 2, b: 2 })
await test.increaseA()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 3, b: 3 })
})
})
describe('onSaveSuccess / onSaveError', () => {
test('onSaveSuccess is called after setState', async () => {
const onSaveSuccess = vi.fn()
const onSaveError = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError
}
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(onSaveSuccess).toHaveBeenCalledTimes(1)
expect(onSaveError).toHaveBeenCalledTimes(0)
test.$patch({ a: 4 })
await flushPromises()
expect(onSaveSuccess).toHaveBeenCalledTimes(2)
expect(onSaveError).toHaveBeenCalledTimes(0)
})
test('onSaveError is called after setState fails', async () => {
mockStorage.setItem = vi.fn(async () => {
throw new Error('cannot save')
})
const onSaveSuccess = vi.fn()
const onSaveError = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError
}
})
const test = useTestStore()
await test.$persistLoaded
await test.$patch({ a: 3 })
expect(onSaveSuccess).toHaveBeenCalledTimes(0)
expect(onSaveError).toHaveBeenCalledTimes(1)
await test.$patch({ a: 4 })
expect(onSaveSuccess).toHaveBeenCalledTimes(0)
expect(onSaveError).toHaveBeenCalledTimes(2)
})
})
describe('afterLoad', () => {
test('it is called with the saved state object', async () => {
await mockStorage.setItem('pinia-local-test', { a: 2 })
const afterLoad = vi.fn(async orig => {
return { a: orig.a + 1 }
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
afterLoad
}
})
const test = useTestStore()
await test.$persistLoaded
expect(afterLoad).toHaveBeenCalledTimes(1)
expect(afterLoad).toHaveBeenCalledWith({ a: 2 })
expect(test.a).to.eql(3)
})
test('it is called with empty object if there is no saved state', async () => {
const afterLoad = vi.fn(async () => {
return { a: 3 }
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
afterLoad
}
})
const test = useTestStore()
await test.$persistLoaded
expect(afterLoad).toHaveBeenCalledTimes(1)
expect(afterLoad).toHaveBeenCalledWith({})
expect(test.a).to.eql(3)
})
})
})

View file

@ -1,38 +1,41 @@
import { createApp } from 'vue'
import { createStore } from 'vuex'
import { createPinia, setActivePinia } from 'pinia'
import { http, HttpResponse } from 'msw'
import oauth from 'src/modules/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { injectMswToTest, authApis, testServer } from '/test/fixtures/mock_api.js'
const test = injectMswToTest(authApis)
const getStore = (defaultStateInjection) => {
const stateFunction = defaultStateInjection ? () => {
return {
...oauth.state(),
...defaultStateInjection
const vuexStore = createStore({
modules: {
instance: {
state: () => ({ server: testServer })
}
} : oauth.state
}
})
const app = createApp({})
app.use(vuexStore)
window.vuex = vuexStore
return createStore({
modules: {
instance: {
state: () => ({ server: testServer })
},
oauth: {
...oauth,
state: stateFunction
}
const getStore = (defaultStateInjection) => {
const pinia = createPinia().use(({ store }) => {
if (store.$id === 'oauth') {
store.$patch(defaultStateInjection)
}
})
app.use(pinia)
setActivePinia(pinia)
return useOAuthStore()
}
describe('createApp', () => {
test('it should use create an app and record client id and secret', async () => {
const store = getStore()
const app = await store.dispatch('createApp')
expect(store.state.oauth.clientId).to.eql('test-id')
expect(store.state.oauth.clientSecret).to.eql('test-secret')
const app = await store.createApp()
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
expect(app.clientId).to.eql('test-id')
expect(app.clientSecret).to.eql('test-secret')
})
@ -45,19 +48,19 @@ describe('createApp', () => {
)
const store = getStore()
const res = store.dispatch('createApp')
const res = store.createApp()
await expect(res).rejects.toThrowError('Throttled')
expect(store.state.oauth.clientId).to.eql(false)
expect(store.state.oauth.clientSecret).to.eql(false)
expect(store.clientId).to.eql(false)
expect(store.clientSecret).to.eql(false)
})
})
describe('ensureApp', () => {
test('it should create an app if it does not exist', async () => {
const store = getStore()
const app = await store.dispatch('ensureApp')
expect(store.state.oauth.clientId).to.eql('test-id')
expect(store.state.oauth.clientSecret).to.eql('test-secret')
const app = await store.ensureApp()
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
expect(app.clientId).to.eql('test-id')
expect(app.clientSecret).to.eql('test-secret')
})
@ -73,9 +76,9 @@ describe('ensureApp', () => {
clientId: 'another-id',
clientSecret: 'another-secret'
})
const app = await store.dispatch('ensureApp')
expect(store.state.oauth.clientId).to.eql('another-id')
expect(store.state.oauth.clientSecret).to.eql('another-secret')
const app = await store.ensureApp()
expect(store.clientId).to.eql('another-id')
expect(store.clientSecret).to.eql('another-secret')
expect(app.clientId).to.eql('another-id')
expect(app.clientSecret).to.eql('another-secret')
})
@ -87,9 +90,9 @@ describe('getAppToken', () => {
clientId: 'test-id',
clientSecret: 'test-secret'
})
const token = await store.dispatch('getAppToken')
const token = await store.getAppToken()
expect(token).to.eql('test-app-token')
expect(store.state.oauth.appToken).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
test('it should throw and not set state if it cannot get app token', async () => {
@ -97,26 +100,26 @@ describe('getAppToken', () => {
clientId: 'bad-id',
clientSecret: 'bad-secret'
})
await expect(store.dispatch('getAppToken')).rejects.toThrowError('400')
expect(store.state.oauth.appToken).to.eql(false)
await expect(store.getAppToken()).rejects.toThrowError('400')
expect(store.appToken).to.eql(false)
})
})
describe('ensureAppToken', () => {
test('it should work if the state is empty', async () => {
const store = getStore()
const token = await store.dispatch('ensureAppToken')
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.state.oauth.appToken).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
test('it should work if we already have a working token', async () => {
const store = getStore({
appToken: 'also-good-app-token'
})
const token = await store.dispatch('ensureAppToken')
const token = await store.ensureAppToken()
expect(token).to.eql('also-good-app-token')
expect(store.state.oauth.appToken).to.eql('also-good-app-token')
expect(store.appToken).to.eql('also-good-app-token')
})
test('it should work if we have a bad token but good app credentials', async ({ worker }) => {
@ -130,9 +133,9 @@ describe('ensureAppToken', () => {
clientId: 'test-id',
clientSecret: 'test-secret'
})
const token = await store.dispatch('ensureAppToken')
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.state.oauth.appToken).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
test('it should work if we have no token but good app credentials', async ({ worker }) => {
@ -145,9 +148,9 @@ describe('ensureAppToken', () => {
clientId: 'test-id',
clientSecret: 'test-secret'
})
const token = await store.dispatch('ensureAppToken')
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.state.oauth.appToken).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
test('it should work if we have no token and bad app credentials', async () => {
@ -155,11 +158,11 @@ describe('ensureAppToken', () => {
clientId: 'bad-id',
clientSecret: 'bad-secret'
})
const token = await store.dispatch('ensureAppToken')
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.state.oauth.appToken).to.eql('test-app-token')
expect(store.state.oauth.clientId).to.eql('test-id')
expect(store.state.oauth.clientSecret).to.eql('test-secret')
expect(store.appToken).to.eql('test-app-token')
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
})
test('it should work if we have bad token and bad app credentials', async () => {
@ -168,11 +171,11 @@ describe('ensureAppToken', () => {
clientId: 'bad-id',
clientSecret: 'bad-secret'
})
const token = await store.dispatch('ensureAppToken')
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.state.oauth.appToken).to.eql('test-app-token')
expect(store.state.oauth.clientId).to.eql('test-id')
expect(store.state.oauth.clientSecret).to.eql('test-secret')
expect(store.appToken).to.eql('test-app-token')
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
})
test('it should throw if we cannot create an app', async ({ worker }) => {
@ -183,7 +186,7 @@ describe('ensureAppToken', () => {
)
const store = getStore()
await expect(store.dispatch('ensureAppToken')).rejects.toThrowError('Throttled')
await expect(store.ensureAppToken()).rejects.toThrowError('Throttled')
})
test('it should throw if we cannot obtain app token', async ({ worker }) => {
@ -194,6 +197,6 @@ describe('ensureAppToken', () => {
)
const store = getStore()
await expect(store.dispatch('ensureAppToken')).rejects.toThrowError('Throttled')
await expect(store.ensureAppToken()).rejects.toThrowError('Throttled')
})
})