fix(mobile): harden shared intake and upload flows

This commit is contained in:
Saravana Achu Mac 2026-05-05 12:58:59 -07:00
parent dd988ba7a5
commit 5e32f16fa3
7 changed files with 172 additions and 4 deletions

View File

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

View File

@ -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<UploadResult> {
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<UploadResult> {
const blobName = normalizeBlobFileName(fileName, 'image.jpg');
return blobClient.upload('attachments', data, {
contentType: 'image/jpeg',
blobName: `notelett/images/${fileName}`,
blobName: `notelett/images/${blobName}`,
});
}

View File

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

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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();
});
});

View File

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