From 9f4516c6a8098c325fa29a2934369c0c44fe7c9e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 14:29:36 -0400 Subject: [PATCH] test: add suites for state/sessions, recentSearches, upload (+17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Via subagent, all verified against real behavior: - state/sessions (5): fallback-session round-trip across the four cinny_* keys, missing-key → undefined for each required key, removeFallbackSession clears all. - state/recentSearches (6): addRecentSearch prepend, case-sensitive dedupe + move-to-front, trim, ignore empty/whitespace, cap at 10. - state/upload (6): the createUploadAtom reducer driven through a real jotai store — idle→loading→progress(gated)→success/error, file ref preserved. No bugs found. Co-Authored-By: Claude Opus 4.8 --- src/app/state/recentSearches.test.ts | 48 +++++++++++++ src/app/state/sessions.test.ts | 74 +++++++++++++++++++ src/app/state/upload.test.ts | 103 +++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 src/app/state/recentSearches.test.ts create mode 100644 src/app/state/sessions.test.ts create mode 100644 src/app/state/upload.test.ts diff --git a/src/app/state/recentSearches.test.ts b/src/app/state/recentSearches.test.ts new file mode 100644 index 000000000..5aeba0647 --- /dev/null +++ b/src/app/state/recentSearches.test.ts @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +// Importing the module evaluates `atomWithStorage(..., createJSONStorage(() => localStorage))`, +// which touches `localStorage` at load time. node has none, so install a no-op +// mock before the import is resolved. +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +// eslint-disable-next-line import/first +import { addRecentSearch } from './recentSearches'; + +test('addRecentSearch prepends a new term', () => { + assert.deepEqual(addRecentSearch(['a', 'b'], 'c'), ['c', 'a', 'b']); +}); + +test('addRecentSearch dedupes (case-sensitive) and moves the term to the front', () => { + assert.deepEqual(addRecentSearch(['a', 'b', 'c'], 'b'), ['b', 'a', 'c']); + // case-sensitive: differing case is treated as a distinct term + assert.deepEqual(addRecentSearch(['a', 'B'], 'b'), ['b', 'a', 'B']); +}); + +test('addRecentSearch trims whitespace before storing', () => { + assert.deepEqual(addRecentSearch(['a'], ' hello '), ['hello', 'a']); + // dedupe compares against the trimmed value + assert.deepEqual(addRecentSearch(['hello'], ' hello '), ['hello']); +}); + +test('addRecentSearch ignores empty / whitespace-only terms', () => { + assert.deepEqual(addRecentSearch(['a', 'b'], ''), ['a', 'b']); + assert.deepEqual(addRecentSearch(['a', 'b'], ' '), ['a', 'b']); +}); + +test('addRecentSearch caps the list at 10 entries', () => { + const ten = Array.from({ length: 10 }, (_, i) => `t${i}`); + const result = addRecentSearch(ten, 'new'); + assert.equal(result.length, 10); + assert.equal(result[0], 'new'); + // the oldest entry (last) is dropped + assert.equal(result.includes('t9'), false); + assert.deepEqual(result.slice(1), ten.slice(0, 9)); +}); + +test('addRecentSearch on an empty history returns a single-element list', () => { + assert.deepEqual(addRecentSearch([], 'first'), ['first']); +}); diff --git a/src/app/state/sessions.test.ts b/src/app/state/sessions.test.ts new file mode 100644 index 000000000..b4b6e00f8 --- /dev/null +++ b/src/app/state/sessions.test.ts @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setFallbackSession, removeFallbackSession, getFallbackSession } from './sessions'; + +// The fallback-session helpers read/write specific `cinny_*` keys directly on +// `localStorage`. node has none, so install a controllable in-memory mock per +// case backed by a Map. +const installStorage = (): Map => { + const store = new Map(); + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key) : null), + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }; + return store; +}; + +test('setFallbackSession writes the cinny_* keys', () => { + const store = installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + + assert.equal(store.get('cinny_access_token'), 'token-1'); + assert.equal(store.get('cinny_device_id'), 'DEVICE1'); + assert.equal(store.get('cinny_user_id'), '@alice:example.org'); + assert.equal(store.get('cinny_hs_base_url'), 'https://hs.example.org'); +}); + +test('getFallbackSession round-trips a full session and flags fallback stores', () => { + installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + + assert.deepEqual(getFallbackSession(), { + baseUrl: 'https://hs.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE1', + accessToken: 'token-1', + fallbackSdkStores: true, + }); +}); + +test('getFallbackSession returns undefined when nothing is stored', () => { + installStorage(); + assert.equal(getFallbackSession(), undefined); +}); + +test('getFallbackSession returns undefined when a single key is missing', () => { + // Every one of the four keys is required; missing any one yields undefined. + const keys = [ + 'cinny_access_token', + 'cinny_device_id', + 'cinny_user_id', + 'cinny_hs_base_url', + ] as const; + + keys.forEach((missing) => { + installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + localStorage.removeItem(missing); + assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`); + }); +}); + +test('removeFallbackSession clears all keys', () => { + const store = installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + removeFallbackSession(); + + assert.equal(store.size, 0); + assert.equal(getFallbackSession(), undefined); +}); diff --git a/src/app/state/upload.test.ts b/src/app/state/upload.test.ts new file mode 100644 index 000000000..dd8390e86 --- /dev/null +++ b/src/app/state/upload.test.ts @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createStore } from 'jotai'; +import { UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; +import { createUploadAtom, UploadStatus, Upload } from './upload'; +import { TUploadContent } from '../utils/matrix'; + +// We exercise the pure reducer inside `createUploadAtom` by driving it through a +// plain jotai store. The write atom is a state machine over UploadStatus. +// +// SKIPPED (require a real MatrixClient and/or React render, not pure logic): +// - useBindUploadAtom (React hook; calls mx.cancelUpload, uploadContent) +// - createUploadAtomFamily / createUploadFamilyObserverAtom (thin atomFamily +// wrappers whose behavior is just createUploadAtom + jotai plumbing) + +const makeFile = (size = 100): TUploadContent => ({ size }) as unknown as TUploadContent; + +test('createUploadAtom starts in the Idle state holding the file', () => { + const store = createStore(); + const file = makeFile(); + const uploadAtom = createUploadAtom(file); + + const state = store.get(uploadAtom); + assert.equal(state.status, UploadStatus.Idle); + assert.equal(state.file, file); +}); + +test('a promise update transitions Idle -> Loading with zeroed progress', () => { + const store = createStore(); + const file = makeFile(2048); + const uploadAtom = createUploadAtom(file); + + const promise = Promise.resolve({} as UploadResponse); + store.set(uploadAtom, { promise }); + + const state = store.get(uploadAtom); + assert.equal(state.status, UploadStatus.Loading); + if (state.status === UploadStatus.Loading) { + assert.equal(state.promise, promise); + assert.deepEqual(state.progress, { loaded: 0, total: 2048 }); + } +}); + +test('a progress update is applied only while Loading', () => { + const store = createStore(); + const uploadAtom = createUploadAtom(makeFile(2048)); + + const progress: UploadProgress = { loaded: 512, total: 2048 }; + + // Ignored while Idle (not Loading). + store.set(uploadAtom, { progress }); + assert.equal(store.get(uploadAtom).status, UploadStatus.Idle); + + // Enter Loading, then progress sticks. + store.set(uploadAtom, { promise: Promise.resolve({} as UploadResponse) }); + store.set(uploadAtom, { progress }); + const state = store.get(uploadAtom); + assert.equal(state.status, UploadStatus.Loading); + if (state.status === UploadStatus.Loading) { + assert.deepEqual(state.progress, progress); + } +}); + +test('an mxc update transitions to Success', () => { + const store = createStore(); + const uploadAtom = createUploadAtom(makeFile()); + + store.set(uploadAtom, { mxc: 'mxc://example.org/abc' }); + const state = store.get(uploadAtom); + assert.equal(state.status, UploadStatus.Success); + if (state.status === UploadStatus.Success) { + assert.equal(state.mxc, 'mxc://example.org/abc'); + } +}); + +test('an error update transitions to Error', () => { + const store = createStore(); + const uploadAtom = createUploadAtom(makeFile()); + + const error = new Error('boom') as unknown as MatrixError; + store.set(uploadAtom, { error }); + const state = store.get(uploadAtom); + assert.equal(state.status, UploadStatus.Error); + if (state.status === UploadStatus.Error) { + assert.equal(state.error, error); + } +}); + +test('the file reference is preserved across transitions', () => { + const store = createStore(); + const file = makeFile(); + const uploadAtom = createUploadAtom(file); + + const seenFiles: TUploadContent[] = []; + const record = (s: Upload) => seenFiles.push(s.file); + + store.set(uploadAtom, { promise: Promise.resolve({} as UploadResponse) }); + record(store.get(uploadAtom)); + store.set(uploadAtom, { mxc: 'mxc://example.org/x' }); + record(store.get(uploadAtom)); + + assert.ok(seenFiles.every((f) => f === file)); +});