diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 155a7250..508da536 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/Makefile b/Makefile index e93b6357..ae5a12eb 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/src/state/persisted/__tests__/fixtures.ts b/src/state/persisted/__tests__/fixtures.ts new file mode 100644 index 00000000..ac8f7c8d --- /dev/null +++ b/src/state/persisted/__tests__/fixtures.ts @@ -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: {}, +} diff --git a/src/state/persisted/__tests__/index.test.ts b/src/state/persisted/__tests__/index.test.ts new file mode 100644 index 00000000..90c5e0e4 --- /dev/null +++ b/src/state/persisted/__tests__/index.test.ts @@ -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') +}) diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts new file mode 100644 index 00000000..d42580ef --- /dev/null +++ b/src/state/persisted/__tests__/migrate.test.ts @@ -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}, + ) +}) diff --git a/src/state/persisted/__tests__/legacy.test.ts b/src/state/persisted/__tests__/schema.test.ts similarity index 50% rename from src/state/persisted/__tests__/legacy.test.ts rename to src/state/persisted/__tests__/schema.test.ts index 7f4b138a..c78a2c27 100644 --- a/src/state/persisted/__tests__/legacy.test.ts +++ b/src/state/persisted/__tests__/schema.test.ts @@ -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) +}) diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index c45b1832..d7026374 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -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 @@ -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')