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 test
zio/stable
Eric Bailey 2023-12-06 18:41:05 -06:00 committed by GitHub
parent 07fe058577
commit 818c6ae879
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 224 additions and 5 deletions

View File

@ -48,4 +48,4 @@ jobs:
run: yarn intl:build
- name: Run tests
run: |
yarn test --forceExit
NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test --forceExit

View File

@ -14,7 +14,7 @@ build-web: ## Compile web bundle, copy to bskyweb directory
.PHONY: test
test: ## Run all tests
yarn test
NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test
.PHONY: lint
lint: ## Run style checks and verify syntax

View File

@ -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: {},
}

View File

@ -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')
})

View File

@ -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},
)
})

View File

@ -2,6 +2,7 @@ import {expect, test} from '@jest/globals'
import {transform} from '#/state/persisted/legacy'
import {defaults, schema} from '#/state/persisted/schema'
import * as fixtures from '#/state/persisted/__tests__/fixtures'
test('defaults', () => {
expect(() => schema.parse(defaults)).not.toThrow()
@ -11,3 +12,10 @@ test('transform', () => {
const data = transform({})
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)
})

View File

@ -7,7 +7,7 @@ import {write, read} from '#/state/persisted/store'
/**
* The shape of the serialized data from our legacy Mobx store.
*/
type LegacySchema = {
export type LegacySchema = {
shell: {
colorMode: 'system' | 'light' | 'dark'
}
@ -15,7 +15,7 @@ type LegacySchema = {
data: {
service: string
did: `did:plc:${string}`
}
} | null
accounts: {
service: string
did: `did:plc:${string}`
@ -61,7 +61,7 @@ type LegacySchema = {
copiedInvites: string[]
}
mutedThreads: {uris: string[]}
reminders: {lastEmailConfirm: string}
reminders: {lastEmailConfirm?: string}
}
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
@ -124,6 +124,7 @@ export async function migrate() {
const newData = await read()
const alreadyMigrated = Boolean(newData)
/* TODO BEGIN DEBUG — remove this eventually */
try {
if (rawLegacyData) {
const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema>
@ -149,6 +150,7 @@ export async function migrate() {
} catch (e: any) {
logger.error(e, {message: `persisted state: legacy debugging failed`})
}
/* TODO END DEBUG */
if (!alreadyMigrated && rawLegacyData) {
logger.info('persisted state: migrating legacy storage')