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 };
|
||||
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user