diff --git a/mobile/src/api/blob-upload.test.ts b/mobile/src/api/blob-upload.test.ts new file mode 100644 index 0000000..92777fd --- /dev/null +++ b/mobile/src/api/blob-upload.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { uploadMock, blobClientMock } = vi.hoisted(() => { + const upload = vi.fn(); + return { + uploadMock: upload, + blobClientMock: { upload }, + }; +}); + +vi.mock('../lib/platform', () => ({ + blobClient: blobClientMock, +})); + +import { getBlobClient, uploadNoteAttachment, uploadNoteImage } from './blob-upload'; + +describe('mobile blob upload helpers', () => { + beforeEach(() => { + uploadMock.mockReset(); + uploadMock.mockResolvedValue({ url: 'https://blob.example/item', blobName: 'stored' }); + }); + + it('uploads note attachments through the shared blob client', async () => { + const data = new Uint8Array([1, 2, 3]); + + await uploadNoteAttachment(data, 'notes.pdf', 'application/pdf'); + + expect(uploadMock).toHaveBeenCalledWith('attachments', data, { + contentType: 'application/pdf', + blobName: 'notelett/attachments/notes.pdf', + }); + }); + + it('normalizes unsafe attachment filenames before creating blob names', async () => { + await uploadNoteAttachment(new Uint8Array([1]), '../Quarterly Plan!.pdf', 'application/pdf'); + + expect(uploadMock).toHaveBeenCalledWith( + 'attachments', + expect.any(Uint8Array), + expect.objectContaining({ + blobName: 'notelett/attachments/Quarterly-Plan.pdf', + }), + ); + }); + + it('uploads note images with the mobile image prefix and JPEG content type', async () => { + await uploadNoteImage(new Uint8Array([1]), 'scan 1.jpg'); + + expect(uploadMock).toHaveBeenCalledWith('attachments', expect.any(Uint8Array), { + contentType: 'image/jpeg', + blobName: 'notelett/images/scan-1.jpg', + }); + }); + + it('returns the shared blob client for advanced flows', () => { + expect(getBlobClient()).toBe(blobClientMock); + }); +}); diff --git a/mobile/src/api/blob-upload.ts b/mobile/src/api/blob-upload.ts index aa74df6..66a6d5a 100644 --- a/mobile/src/api/blob-upload.ts +++ b/mobile/src/api/blob-upload.ts @@ -12,6 +12,16 @@ import type { UploadResult } from '@bytelyst/blob-client'; export type { UploadResult }; +function normalizeBlobFileName(fileName: string, fallback: string): string { + const baseName = fileName.split(/[\\/]/).pop()?.trim() ?? ''; + const safeName = baseName + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9._-]/g, '') + .replace(/^\.+/, ''); + + return safeName || fallback; +} + /** * Upload a note attachment (image, PDF, audio, etc.). * Returns the blob URL and metadata. @@ -21,9 +31,10 @@ export async function uploadNoteAttachment( fileName: string, contentType: string, ): Promise { + const blobName = normalizeBlobFileName(fileName, 'attachment'); return blobClient.upload('attachments', data, { contentType, - blobName: `notelett/attachments/${fileName}`, + blobName: `notelett/attachments/${blobName}`, }); } @@ -34,9 +45,10 @@ export async function uploadNoteImage( data: Blob | ArrayBuffer | Uint8Array, fileName: string, ): Promise { + const blobName = normalizeBlobFileName(fileName, 'image.jpg'); return blobClient.upload('attachments', data, { contentType: 'image/jpeg', - blobName: `notelett/images/${fileName}`, + blobName: `notelett/images/${blobName}`, }); } diff --git a/mobile/src/app/intake.tsx b/mobile/src/app/intake.tsx index ec90951..7a6b544 100644 --- a/mobile/src/app/intake.tsx +++ b/mobile/src/app/intake.tsx @@ -106,7 +106,10 @@ export default function IntakeScreen() { waitForJob(submitResult.jobId, (job) => { if (job.status === 'complete') { router.push(`/note/${job.noteId}`); + return; } + setError(job.error ?? 'Processing failed'); + setResult(null); }); } catch (err) { setError(err instanceof Error ? err.message : 'Intake submission failed'); diff --git a/mobile/src/lib/share-intent.test.ts b/mobile/src/lib/share-intent.test.ts new file mode 100644 index 0000000..ca3c552 --- /dev/null +++ b/mobile/src/lib/share-intent.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { pushMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), +})); + +vi.mock('expo-router', () => ({ + router: { + push: pushMock, + }, +})); + +import { extractSharedUrl, handleShareIntent } from './share-intent'; + +describe('mobile share intent handling', () => { + beforeEach(() => { + pushMock.mockReset(); + }); + + it('extracts and normalizes explicit shared web URLs', () => { + expect(extractSharedUrl({ webUrl: ' https://example.com/article). ' })).toBe('https://example.com/article'); + }); + + it('extracts URLs from shared text bodies', () => { + expect(extractSharedUrl({ text: 'Read this https://example.com/post, please' })).toBe('https://example.com/post'); + }); + + it('ignores non-http share data', () => { + expect(extractSharedUrl({ text: 'plain text note', webUrl: 'notelett://note/n1' })).toBeNull(); + }); + + it('routes valid share intents to the intake confirmation screen', () => { + handleShareIntent({ text: 'Saved from https://example.com/post!' }); + + expect(pushMock).toHaveBeenCalledWith({ + pathname: '/intake', + params: { url: 'https://example.com/post' }, + }); + }); + + it('does not navigate when no URL is present', () => { + handleShareIntent({ text: 'No link here' }); + + expect(pushMock).not.toHaveBeenCalled(); + }); +}); diff --git a/mobile/src/lib/share-intent.ts b/mobile/src/lib/share-intent.ts index 462760c..654f12d 100644 --- a/mobile/src/lib/share-intent.ts +++ b/mobile/src/lib/share-intent.ts @@ -21,14 +21,27 @@ export type ShareIntentData = { * Extracts the URL (if any) and navigates to the intake screen. */ export function handleShareIntent(data: ShareIntentData): void { - const url = data.webUrl ?? extractUrl(data.text); + const url = extractSharedUrl(data); if (!url) return; router.push({ pathname: '/intake', params: { url } }); } +export function extractSharedUrl(data: ShareIntentData): string | null { + return normalizeUrl(data.webUrl) ?? extractUrl(data.text); +} + +function normalizeUrl(url?: string): string | null { + const trimmed = url?.trim(); + if (!trimmed || !/^https?:\/\//i.test(trimmed)) { + return null; + } + + return trimmed.replace(/[),.;!?]+$/, ''); +} + function extractUrl(text?: string): string | null { if (!text) return null; const match = text.match(/https?:\/\/[^\s]+/); - return match ? match[0] : null; + return match ? normalizeUrl(match[0]) : null; } diff --git a/mobile/src/store/intake-store.test.ts b/mobile/src/store/intake-store.test.ts index d41d477..0928635 100644 --- a/mobile/src/store/intake-store.test.ts +++ b/mobile/src/store/intake-store.test.ts @@ -74,4 +74,19 @@ describe('useIntakeStore', () => { expect(useIntakeStore.getState().completedJobIds).toEqual(['job-1']); vi.useRealTimers(); }); + + it('waitForJob surfaces failed jobs to the caller for failure UI', async () => { + vi.useFakeTimers(); + const onComplete = vi.fn(); + const failedJob = makeJob({ status: 'failed', error: 'Extraction failed' }); + useIntakeStore.setState({ activeJobs: [makeJob()] }); + getIntakeJobMock.mockResolvedValueOnce(failedJob); + + useIntakeStore.getState().waitForJob('job-1', onComplete); + await vi.advanceTimersByTimeAsync(3000); + + expect(onComplete).toHaveBeenCalledWith(failedJob); + expect(useIntakeStore.getState().completedJobIds).toEqual(['job-1']); + vi.useRealTimers(); + }); }); diff --git a/mobile/src/store/prompt-store.test.ts b/mobile/src/store/prompt-store.test.ts index 2eff7f2..ac812da 100644 --- a/mobile/src/store/prompt-store.test.ts +++ b/mobile/src/store/prompt-store.test.ts @@ -79,6 +79,13 @@ describe('prompt-store', () => { }); it('runPrompt sets error on failure', async () => { + usePromptStore.setState({ + lastResult: { + content: 'Old result', + templateSlug: 'old', + outputType: 'new_note', + }, + }); runPromptMock.mockRejectedValue(new Error('LLM timeout')); const result = await usePromptStore.getState().runPrompt({ @@ -90,9 +97,23 @@ describe('prompt-store', () => { expect(result).toBeNull(); const state = usePromptStore.getState(); expect(state.error).toBe('LLM timeout'); + expect(state.lastResult).toBeNull(); expect(state.isRunning).toBe(false); }); + it('runPrompt uses a fallback error for non-Error failures', async () => { + runPromptMock.mockRejectedValue('offline'); + + const result = await usePromptStore.getState().runPrompt({ + templateId: 'summarize', + noteId: 'n1', + workspaceId: 'ws1', + }); + + expect(result).toBeNull(); + expect(usePromptStore.getState().error).toBe('Prompt execution failed'); + }); + it('clearResult resets lastResult', async () => { runPromptMock.mockResolvedValue({ content: 'Result', templateSlug: 'x', outputType: 'new_note' }); await usePromptStore.getState().runPrompt({ templateId: 'x', noteId: 'n1', workspaceId: 'ws1' });