Add tests for migration and persisted state (#2118)
* Add tests for migrate * Add test for persisted.init * Add legacy transform test * Set NODE_ENV for testing * Mock logger * Set expo var to testzio/stable
parent
07fe058577
commit
818c6ae879
|
@ -48,4 +48,4 @@ jobs:
|
||||||
run: yarn intl:build
|
run: yarn intl:build
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
yarn test --forceExit
|
NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test --forceExit
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -14,7 +14,7 @@ build-web: ## Compile web bundle, copy to bskyweb directory
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## Run all tests
|
test: ## Run all tests
|
||||||
yarn test
|
NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: ## Run style checks and verify syntax
|
lint: ## Run style checks and verify syntax
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type {LegacySchema} from '#/state/persisted/legacy'
|
||||||
|
|
||||||
|
export const ALICE_DID = 'did:plc:ALICE_DID'
|
||||||
|
export const BOB_DID = 'did:plc:BOB_DID'
|
||||||
|
|
||||||
|
export const LEGACY_DATA_DUMP: LegacySchema = {
|
||||||
|
session: {
|
||||||
|
data: {
|
||||||
|
service: 'https://bsky.social/',
|
||||||
|
did: ALICE_DID,
|
||||||
|
},
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
service: 'https://bsky.social',
|
||||||
|
did: ALICE_DID,
|
||||||
|
refreshJwt: 'refreshJwt',
|
||||||
|
accessJwt: 'accessJwt',
|
||||||
|
handle: 'alice.test',
|
||||||
|
email: 'alice@bsky.test',
|
||||||
|
displayName: 'Alice',
|
||||||
|
aviUrl: 'avi',
|
||||||
|
emailConfirmed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: 'https://bsky.social',
|
||||||
|
did: BOB_DID,
|
||||||
|
refreshJwt: 'refreshJwt',
|
||||||
|
accessJwt: 'accessJwt',
|
||||||
|
handle: 'bob.test',
|
||||||
|
email: 'bob@bsky.test',
|
||||||
|
displayName: 'Bob',
|
||||||
|
aviUrl: 'avi',
|
||||||
|
emailConfirmed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
me: {
|
||||||
|
did: ALICE_DID,
|
||||||
|
handle: 'alice.test',
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: '',
|
||||||
|
avatar: 'avi',
|
||||||
|
},
|
||||||
|
onboarding: {step: 'Home'},
|
||||||
|
shell: {colorMode: 'system'},
|
||||||
|
preferences: {
|
||||||
|
primaryLanguage: 'en',
|
||||||
|
contentLanguages: ['en'],
|
||||||
|
postLanguage: 'en',
|
||||||
|
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
|
||||||
|
contentLabels: {
|
||||||
|
nsfw: 'warn',
|
||||||
|
nudity: 'warn',
|
||||||
|
suggestive: 'warn',
|
||||||
|
gore: 'warn',
|
||||||
|
hate: 'hide',
|
||||||
|
spam: 'hide',
|
||||||
|
impersonation: 'warn',
|
||||||
|
},
|
||||||
|
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
|
||||||
|
pinnedFeeds: ['feed_a', 'feed_b'],
|
||||||
|
requireAltTextEnabled: false,
|
||||||
|
},
|
||||||
|
invitedUsers: {seenDids: [], copiedInvites: []},
|
||||||
|
mutedThreads: {uris: []},
|
||||||
|
reminders: {},
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import {jest, expect, test, afterEach} from '@jest/globals'
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
import {defaults} from '#/state/persisted/schema'
|
||||||
|
import {migrate} from '#/state/persisted/legacy'
|
||||||
|
import * as store from '#/state/persisted/store'
|
||||||
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
|
const write = jest.mocked(store.write)
|
||||||
|
const read = jest.mocked(store.read)
|
||||||
|
|
||||||
|
jest.mock('#/logger')
|
||||||
|
jest.mock('#/state/persisted/legacy', () => ({
|
||||||
|
migrate: jest.fn(),
|
||||||
|
}))
|
||||||
|
jest.mock('#/state/persisted/store', () => ({
|
||||||
|
write: jest.fn(),
|
||||||
|
read: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
jest.clearAllMocks()
|
||||||
|
AsyncStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('init: fresh install, no migration', async () => {
|
||||||
|
await persisted.init()
|
||||||
|
|
||||||
|
expect(migrate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(read).toHaveBeenCalledTimes(1)
|
||||||
|
expect(write).toHaveBeenCalledWith(defaults)
|
||||||
|
|
||||||
|
// default value
|
||||||
|
expect(persisted.get('colorMode')).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('init: fresh install, migration ran', async () => {
|
||||||
|
read.mockResolvedValueOnce(defaults)
|
||||||
|
|
||||||
|
await persisted.init()
|
||||||
|
|
||||||
|
expect(migrate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(read).toHaveBeenCalledTimes(1)
|
||||||
|
expect(write).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// default value
|
||||||
|
expect(persisted.get('colorMode')).toBe('system')
|
||||||
|
})
|
|
@ -0,0 +1,93 @@
|
||||||
|
import {jest, expect, test, afterEach} from '@jest/globals'
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
import {defaults, schema} from '#/state/persisted/schema'
|
||||||
|
import {transform, migrate} from '#/state/persisted/legacy'
|
||||||
|
import * as store from '#/state/persisted/store'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
||||||
|
|
||||||
|
const write = jest.mocked(store.write)
|
||||||
|
const read = jest.mocked(store.read)
|
||||||
|
|
||||||
|
jest.mock('#/logger')
|
||||||
|
jest.mock('#/state/persisted/store', () => ({
|
||||||
|
write: jest.fn(),
|
||||||
|
read: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
AsyncStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('migrate: fresh install', async () => {
|
||||||
|
await migrate()
|
||||||
|
|
||||||
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||||
|
expect(read).toHaveBeenCalledTimes(1)
|
||||||
|
expect(logger.log).toHaveBeenCalledWith(
|
||||||
|
'persisted state: no migration needed',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('migrate: fresh install, existing new storage', async () => {
|
||||||
|
read.mockResolvedValueOnce(defaults)
|
||||||
|
|
||||||
|
await migrate()
|
||||||
|
|
||||||
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||||
|
expect(read).toHaveBeenCalledTimes(1)
|
||||||
|
expect(logger.log).toHaveBeenCalledWith(
|
||||||
|
'persisted state: no migration needed',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('migrate: fresh install, AsyncStorage error', async () => {
|
||||||
|
const prevGetItem = AsyncStorage.getItem
|
||||||
|
|
||||||
|
const error = new Error('test error')
|
||||||
|
|
||||||
|
AsyncStorage.getItem = jest.fn(() => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
await migrate()
|
||||||
|
|
||||||
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(error, {
|
||||||
|
message: 'persisted state: error migrating legacy storage',
|
||||||
|
})
|
||||||
|
|
||||||
|
AsyncStorage.getItem = prevGetItem
|
||||||
|
})
|
||||||
|
|
||||||
|
test('migrate: has legacy data', async () => {
|
||||||
|
await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
|
||||||
|
|
||||||
|
await migrate()
|
||||||
|
|
||||||
|
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
||||||
|
expect(logger.log).toHaveBeenCalledWith(
|
||||||
|
'persisted state: migrated legacy storage',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('migrate: has legacy data, fails validation', async () => {
|
||||||
|
const legacy = fixtures.LEGACY_DATA_DUMP
|
||||||
|
// @ts-ignore
|
||||||
|
legacy.shell.colorMode = 'invalid'
|
||||||
|
await AsyncStorage.setItem('root', JSON.stringify(legacy))
|
||||||
|
|
||||||
|
await migrate()
|
||||||
|
|
||||||
|
const transformed = transform(legacy)
|
||||||
|
const validate = schema.safeParse(transformed)
|
||||||
|
|
||||||
|
expect(write).not.toHaveBeenCalled()
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
'persisted state: legacy data failed validation',
|
||||||
|
// @ts-ignore
|
||||||
|
{error: validate.error},
|
||||||
|
)
|
||||||
|
})
|
|
@ -2,6 +2,7 @@ import {expect, test} from '@jest/globals'
|
||||||
|
|
||||||
import {transform} from '#/state/persisted/legacy'
|
import {transform} from '#/state/persisted/legacy'
|
||||||
import {defaults, schema} from '#/state/persisted/schema'
|
import {defaults, schema} from '#/state/persisted/schema'
|
||||||
|
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
||||||
|
|
||||||
test('defaults', () => {
|
test('defaults', () => {
|
||||||
expect(() => schema.parse(defaults)).not.toThrow()
|
expect(() => schema.parse(defaults)).not.toThrow()
|
||||||
|
@ -11,3 +12,10 @@ test('transform', () => {
|
||||||
const data = transform({})
|
const data = transform({})
|
||||||
expect(() => schema.parse(data)).not.toThrow()
|
expect(() => schema.parse(data)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('transform: legacy fixture', () => {
|
||||||
|
const data = transform(fixtures.LEGACY_DATA_DUMP)
|
||||||
|
expect(() => schema.parse(data)).not.toThrow()
|
||||||
|
expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
|
||||||
|
expect(data.session.accounts.length).toEqual(2)
|
||||||
|
})
|
|
@ -7,7 +7,7 @@ import {write, read} from '#/state/persisted/store'
|
||||||
/**
|
/**
|
||||||
* The shape of the serialized data from our legacy Mobx store.
|
* The shape of the serialized data from our legacy Mobx store.
|
||||||
*/
|
*/
|
||||||
type LegacySchema = {
|
export type LegacySchema = {
|
||||||
shell: {
|
shell: {
|
||||||
colorMode: 'system' | 'light' | 'dark'
|
colorMode: 'system' | 'light' | 'dark'
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ type LegacySchema = {
|
||||||
data: {
|
data: {
|
||||||
service: string
|
service: string
|
||||||
did: `did:plc:${string}`
|
did: `did:plc:${string}`
|
||||||
}
|
} | null
|
||||||
accounts: {
|
accounts: {
|
||||||
service: string
|
service: string
|
||||||
did: `did:plc:${string}`
|
did: `did:plc:${string}`
|
||||||
|
@ -61,7 +61,7 @@ type LegacySchema = {
|
||||||
copiedInvites: string[]
|
copiedInvites: string[]
|
||||||
}
|
}
|
||||||
mutedThreads: {uris: string[]}
|
mutedThreads: {uris: string[]}
|
||||||
reminders: {lastEmailConfirm: string}
|
reminders: {lastEmailConfirm?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
||||||
|
@ -124,6 +124,7 @@ export async function migrate() {
|
||||||
const newData = await read()
|
const newData = await read()
|
||||||
const alreadyMigrated = Boolean(newData)
|
const alreadyMigrated = Boolean(newData)
|
||||||
|
|
||||||
|
/* TODO BEGIN DEBUG — remove this eventually */
|
||||||
try {
|
try {
|
||||||
if (rawLegacyData) {
|
if (rawLegacyData) {
|
||||||
const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema>
|
const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema>
|
||||||
|
@ -149,6 +150,7 @@ export async function migrate() {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e, {message: `persisted state: legacy debugging failed`})
|
logger.error(e, {message: `persisted state: legacy debugging failed`})
|
||||||
}
|
}
|
||||||
|
/* TODO END DEBUG */
|
||||||
|
|
||||||
if (!alreadyMigrated && rawLegacyData) {
|
if (!alreadyMigrated && rawLegacyData) {
|
||||||
logger.info('persisted state: migrating legacy storage')
|
logger.info('persisted state: migrating legacy storage')
|
||||||
|
|
Loading…
Reference in New Issue