fix(mobile): harden shared intake and upload flows
This commit is contained in:
parent
dd988ba7a5
commit
5e32f16fa3
58
mobile/src/api/blob-upload.test.ts
Normal file
58
mobile/src/api/blob-upload.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,6 +12,16 @@ import type { UploadResult } from '@bytelyst/blob-client';
|
|||||||
|
|
||||||
export type { UploadResult };
|
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.).
|
* Upload a note attachment (image, PDF, audio, etc.).
|
||||||
* Returns the blob URL and metadata.
|
* Returns the blob URL and metadata.
|
||||||
@ -21,9 +31,10 @@ export async function uploadNoteAttachment(
|
|||||||
fileName: string,
|
fileName: string,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
|
const blobName = normalizeBlobFileName(fileName, 'attachment');
|
||||||
return blobClient.upload('attachments', data, {
|
return blobClient.upload('attachments', data, {
|
||||||
contentType,
|
contentType,
|
||||||
blobName: `notelett/attachments/${fileName}`,
|
blobName: `notelett/attachments/${blobName}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,9 +45,10 @@ export async function uploadNoteImage(
|
|||||||
data: Blob | ArrayBuffer | Uint8Array,
|
data: Blob | ArrayBuffer | Uint8Array,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
|
const blobName = normalizeBlobFileName(fileName, 'image.jpg');
|
||||||
return blobClient.upload('attachments', data, {
|
return blobClient.upload('attachments', data, {
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
blobName: `notelett/images/${fileName}`,
|
blobName: `notelett/images/${blobName}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,10 @@ export default function IntakeScreen() {
|
|||||||
waitForJob(submitResult.jobId, (job) => {
|
waitForJob(submitResult.jobId, (job) => {
|
||||||
if (job.status === 'complete') {
|
if (job.status === 'complete') {
|
||||||
router.push(`/note/${job.noteId}`);
|
router.push(`/note/${job.noteId}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setError(job.error ?? 'Processing failed');
|
||||||
|
setResult(null);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Intake submission failed');
|
setError(err instanceof Error ? err.message : 'Intake submission failed');
|
||||||
|
|||||||
46
mobile/src/lib/share-intent.test.ts
Normal file
46
mobile/src/lib/share-intent.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -21,14 +21,27 @@ export type ShareIntentData = {
|
|||||||
* Extracts the URL (if any) and navigates to the intake screen.
|
* Extracts the URL (if any) and navigates to the intake screen.
|
||||||
*/
|
*/
|
||||||
export function handleShareIntent(data: ShareIntentData): void {
|
export function handleShareIntent(data: ShareIntentData): void {
|
||||||
const url = data.webUrl ?? extractUrl(data.text);
|
const url = extractSharedUrl(data);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
router.push({ pathname: '/intake', params: { url } });
|
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 {
|
function extractUrl(text?: string): string | null {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const match = text.match(/https?:\/\/[^\s]+/);
|
const match = text.match(/https?:\/\/[^\s]+/);
|
||||||
return match ? match[0] : null;
|
return match ? normalizeUrl(match[0]) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,4 +74,19 @@ describe('useIntakeStore', () => {
|
|||||||
expect(useIntakeStore.getState().completedJobIds).toEqual(['job-1']);
|
expect(useIntakeStore.getState().completedJobIds).toEqual(['job-1']);
|
||||||
vi.useRealTimers();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -79,6 +79,13 @@ describe('prompt-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runPrompt sets error on failure', async () => {
|
it('runPrompt sets error on failure', async () => {
|
||||||
|
usePromptStore.setState({
|
||||||
|
lastResult: {
|
||||||
|
content: 'Old result',
|
||||||
|
templateSlug: 'old',
|
||||||
|
outputType: 'new_note',
|
||||||
|
},
|
||||||
|
});
|
||||||
runPromptMock.mockRejectedValue(new Error('LLM timeout'));
|
runPromptMock.mockRejectedValue(new Error('LLM timeout'));
|
||||||
|
|
||||||
const result = await usePromptStore.getState().runPrompt({
|
const result = await usePromptStore.getState().runPrompt({
|
||||||
@ -90,9 +97,23 @@ describe('prompt-store', () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
const state = usePromptStore.getState();
|
const state = usePromptStore.getState();
|
||||||
expect(state.error).toBe('LLM timeout');
|
expect(state.error).toBe('LLM timeout');
|
||||||
|
expect(state.lastResult).toBeNull();
|
||||||
expect(state.isRunning).toBe(false);
|
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 () => {
|
it('clearResult resets lastResult', async () => {
|
||||||
runPromptMock.mockResolvedValue({ content: 'Result', templateSlug: 'x', outputType: 'new_note' });
|
runPromptMock.mockResolvedValue({ content: 'Result', templateSlug: 'x', outputType: 'new_note' });
|
||||||
await usePromptStore.getState().runPrompt({ templateId: 'x', noteId: 'n1', workspaceId: 'ws1' });
|
await usePromptStore.getState().runPrompt({ templateId: 'x', noteId: 'n1', workspaceId: 'ws1' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user