diff --git a/packages/web/src/stores/transferStore.test.ts b/packages/web/src/stores/transferStore.test.ts new file mode 100644 index 00000000..bc55706c --- /dev/null +++ b/packages/web/src/stores/transferStore.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { useTransferStore } from './transferStore'; + +describe('transferStore basics', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + if (typeof localStorage !== 'undefined') localStorage.clear(); + }); + + it('creates a transfer with queued state and unique id', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + const t = useTransferStore.getState().get(id); + expect(t).toBeDefined(); + expect(t!.state).toBe('queued'); + expect(t!.progress).toEqual({ loaded: 0, total: 100 }); + }); + + it('createTransfer assigns unique ids across repeated calls', () => { + const a = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a', size: 1, mimetype: 'image/png' }, + tray: true, + }); + const b = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'b', size: 1, mimetype: 'image/png' }, + tray: true, + }); + expect(a).not.toBe(b); + }); + + it('updateProgress mutates only the loaded count', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 1000, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().updateProgress(id, 250); + const t = useTransferStore.getState().get(id)!; + expect(t.progress.loaded).toBe(250); + expect(t.progress.total).toBe(1000); + }); + + it('updateProgress on a missing id is a no-op', () => { + useTransferStore.getState().updateProgress('not-real', 100); + expect(useTransferStore.getState().get('not-real')).toBeUndefined(); + }); + + it('setState_ moves through the state machine', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + const s = useTransferStore.getState(); + s.setState_(id, 'active'); + s.setState_(id, 'paused'); + s.setState_(id, 'completed'); + expect(useTransferStore.getState().get(id)!.state).toBe('completed'); + }); + + it('setError marks the transfer failed and stores the error payload', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().setError(id, { message: 'boom', permanent: true }); + const t = useTransferStore.getState().get(id)!; + expect(t.state).toBe('failed'); + expect(t.error).toEqual({ message: 'boom', permanent: true }); + }); + + it('setTusUrl + setAttachmentId stores the metadata', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a', size: 1, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().setTusUrl(id, '/api/files/abc', 5_000); + useTransferStore.getState().setAttachmentId(id, 'att-9'); + const t = useTransferStore.getState().get(id)!; + expect(t.tusUploadUrl).toBe('/api/files/abc'); + expect(t.tusExpiresAt).toBe(5_000); + expect(t.attachmentId).toBe('att-9'); + }); + + it('remove drops a transfer; idempotent on missing id', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a', size: 1, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().remove(id); + expect(useTransferStore.getState().get(id)).toBeUndefined(); + // Idempotent + expect(() => useTransferStore.getState().remove(id)).not.toThrow(); + expect(() => useTransferStore.getState().remove('not-real')).not.toThrow(); + }); + + it('listVisible omits tray:false transfers', () => { + useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'visible.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'hidden.png', size: 100, mimetype: 'image/png' }, + tray: false, + }); + expect(useTransferStore.getState().listVisible().length).toBe(1); + expect(useTransferStore.getState().listVisible()[0]!.file.name).toBe('visible.png'); + }); + + it('listForChannel filters by channelId', () => { + useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a', size: 1, mimetype: 'image/png' }, + tray: true, + channelId: 'ch-1', + }); + useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'b', size: 1, mimetype: 'image/png' }, + tray: true, + channelId: 'ch-2', + }); + expect(useTransferStore.getState().listForChannel('ch-1').length).toBe(1); + expect(useTransferStore.getState().listForChannel('ch-2').length).toBe(1); + expect(useTransferStore.getState().listForChannel('ch-missing').length).toBe(0); + }); +}); + +describe('transferStore persistence (localStorage)', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + if (typeof localStorage !== 'undefined') localStorage.clear(); + }); + + it('persists a transfer that has a tusUploadUrl across explicit rehydrate', async () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().setTusUrl(id, '/api/files/abc', Date.now() + 60_000); + + // Force flush — zustand persist writes synchronously to localStorage on update. + // Read back from localStorage and confirm the transfer is in the serialized payload. + const raw = localStorage.getItem('transferStore@v1'); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.state.transfers.length).toBe(1); + expect(parsed.state.transfers[0][1].tusUploadUrl).toBe('/api/files/abc'); + }); + + it('does NOT persist queued transfers without a tusUploadUrl or attachmentId', () => { + useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + const raw = localStorage.getItem('transferStore@v1'); + if (raw) { + const parsed = JSON.parse(raw); + expect(parsed.state.transfers.length).toBe(0); + } + // If raw is null, that also means "did not persist" — pass. + }); + + it('does persist failed transfers (user can retry)', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().setError(id, { message: 'oops', permanent: false }); + const raw = localStorage.getItem('transferStore@v1'); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.state.transfers.length).toBe(1); + expect(parsed.state.transfers[0][1].state).toBe('failed'); + }); +}); diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts new file mode 100644 index 00000000..580fcd94 --- /dev/null +++ b/packages/web/src/stores/transferStore.ts @@ -0,0 +1,202 @@ +import { create } from 'zustand'; +import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware'; + +export type TransferType = 'upload' | 'download'; +export type TransferState = + | 'queued' | 'active' | 'paused' | 'completed' | 'failed' | 'aborted'; + +export interface TransferError { + message: string; + permanent: boolean; +} + +export interface Transfer { + id: string; + type: TransferType; + state: TransferState; + file: { name: string; size: number; mimetype: string }; + progress: { loaded: number; total: number }; + error?: TransferError; + channelId?: string; + tray: boolean; + origin?: string; + + // Upload-specific + tusUploadUrl?: string; + tusExpiresAt?: number; + fileHandleId?: string; + attachmentId?: string; + uploaderUserId?: string; + + // Download-specific + sourceUrl?: string; + destFileHandleId?: string; + bytesWrittenToDisk?: number; +} + +export interface CreateTransferInput { + type: TransferType; + file: { name: string; size: number; mimetype: string }; + tray: boolean; + channelId?: string; + origin?: string; + fileHandleId?: string; + destFileHandleId?: string; + sourceUrl?: string; + uploaderUserId?: string; +} + +interface TransferStoreState { + transfers: Map; +} + +interface TransferStoreActions { + createTransfer: (input: CreateTransferInput) => string; + setState_: (id: string, state: TransferState) => void; + updateProgress: (id: string, loaded: number) => void; + setError: (id: string, error: TransferError) => void; + setTusUrl: (id: string, url: string, expiresAt: number) => void; + setAttachmentId: (id: string, attachmentId: string) => void; + remove: (id: string) => void; + + get: (id: string) => Transfer | undefined; + listVisible: () => Transfer[]; + listForChannel: (channelId: string) => Transfer[]; +} + +type TransferStore = TransferStoreState & TransferStoreActions; + +function uuid(): string { + return crypto.randomUUID(); +} + +// Custom storage that serializes Map as an array of entries. +// Only the `transfers` slice is persisted; partialize controls which entries. +const mapAwareStorage: PersistStorage> = { + getItem: (name) => { + const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(name) : null; + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { state: { transfers: [string, Transfer][] }; version?: number }; + const stateOut: Pick = { + transfers: new Map(parsed.state.transfers ?? []), + }; + return { state: stateOut, version: parsed.version } as StorageValue>; + } catch { + return null; + } + }, + setItem: (name, value) => { + if (typeof localStorage === 'undefined') return; + const entries = Array.from(value.state.transfers.entries()); + const payload = JSON.stringify({ state: { transfers: entries }, version: value.version }); + localStorage.setItem(name, payload); + }, + removeItem: (name) => { + if (typeof localStorage !== 'undefined') localStorage.removeItem(name); + }, +}; + +export const useTransferStore = create()( + persist( + (set, get) => ({ + transfers: new Map(), + + createTransfer: (input) => { + const id = uuid(); + const t: Transfer = { + id, + type: input.type, + state: 'queued', + file: input.file, + progress: { loaded: 0, total: input.file.size }, + tray: input.tray, + channelId: input.channelId, + origin: input.origin, + fileHandleId: input.fileHandleId, + destFileHandleId: input.destFileHandleId, + sourceUrl: input.sourceUrl, + uploaderUserId: input.uploaderUserId, + }; + set((s) => { + const next = new Map(s.transfers); + next.set(id, t); + return { transfers: next }; + }); + return id; + }, + + setState_: (id, state) => set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, state }); + return { transfers: next }; + }), + + updateProgress: (id, loaded) => set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, progress: { ...t.progress, loaded } }); + return { transfers: next }; + }), + + setError: (id, error) => set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, error, state: 'failed' }); + return { transfers: next }; + }), + + setTusUrl: (id, tusUploadUrl, tusExpiresAt) => set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, tusUploadUrl, tusExpiresAt }); + return { transfers: next }; + }), + + setAttachmentId: (id, attachmentId) => set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, attachmentId }); + return { transfers: next }; + }), + + remove: (id) => set((s) => { + if (!s.transfers.has(id)) return s; + const next = new Map(s.transfers); + next.delete(id); + return { transfers: next }; + }), + + get: (id) => get().transfers.get(id), + listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray), + listForChannel: (channelId) => + Array.from(get().transfers.values()).filter((t) => t.channelId === channelId), + }), + { + name: 'transferStore@v1', + storage: mapAwareStorage, + version: 1, + // Only persist transfers that are meaningful to rehydrate: + // - completed uploads waiting for the deferred POST (have attachmentId) + // - in-flight uploads with a tusUploadUrl (resumable on reload) + // - failed transfers (user may want to retry / discard) + // Drop active in-flight without a tusUrl (their state is lost), drop aborted (user discarded). + partialize: (s) => ({ + transfers: new Map( + Array.from(s.transfers.entries()).filter(([, t]) => + t.attachmentId !== undefined || + t.tusUploadUrl !== undefined || + t.state === 'failed' || + t.state === 'paused' + ) + ), + }), + }, + ), +);