diff --git a/products/desktop/apps/mobilehog/package.json b/products/desktop/apps/mobilehog/package.json index 93ded301f66c..fc479875ab34 100644 --- a/products/desktop/apps/mobilehog/package.json +++ b/products/desktop/apps/mobilehog/package.json @@ -9,6 +9,7 @@ "prebuild": "expo prebuild", "prebuild:clean": "expo prebuild --clean", "typecheck": "tsc --noEmit", + "test": "vitest run", "lint": "biome check .", "lint:fix": "biome check --write .", "clean": "node ../../scripts/rimraf.mjs .turbo .expo" @@ -46,6 +47,7 @@ "expo-crypto": "~57.0.1", "expo-dev-client": "~57.0.19", "expo-device": "~57.0.2", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-glass-effect": "~57.0.3", "expo-haptics": "~57.0.3", @@ -78,7 +80,8 @@ }, "devDependencies": { "@types/react": "^19.2.0", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^4.1.8" }, "private": true } diff --git a/products/desktop/apps/mobilehog/src/lib/attachments.test.ts b/products/desktop/apps/mobilehog/src/lib/attachments.test.ts new file mode 100644 index 000000000000..c98fb3d06389 --- /dev/null +++ b/products/desktop/apps/mobilehog/src/lib/attachments.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const photoBytes = new Uint8Array([137, 80, 78, 71]); + class MockFile extends Blob { + constructor(_uri: string) { + super([photoBytes], { type: "image/png" }); + } + } + + return { + client: { + finalizeTaskStagedArtifactUploads: vi.fn(), + prepareTaskStagedArtifactUploads: vi.fn(), + }, + expoFetch: vi.fn(), + MockFile, + photoBytes, + }; +}); + +vi.mock("expo/fetch", () => ({ fetch: mocks.expoFetch })); +vi.mock("expo-file-system", () => ({ File: mocks.MockFile })); +vi.mock("expo-image-picker", () => ({ + UIImagePickerPreferredAssetRepresentationMode: { Compatible: "compatible" }, + launchImageLibraryAsync: vi.fn(), +})); +vi.mock("@/lib/client", () => ({ getClient: () => mocks.client })); + +import { uploadStagedPhotos } from "./attachments"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.client.prepareTaskStagedArtifactUploads.mockResolvedValue([ + { + id: "prepared-id", + presigned_post: { + fields: { key: "artifact-key" }, + url: "https://uploads.example.com", + }, + }, + ]); + mocks.client.finalizeTaskStagedArtifactUploads.mockResolvedValue([ + { id: "artifact-id" }, + ]); +}); + +describe("uploadStagedPhotos", () => { + it("sends the selected photo bytes through Expo fetch", async () => { + let uploadedBytes: Uint8Array | undefined; + mocks.expoFetch.mockImplementation(async (_url, init) => { + const file = (init?.body as FormData).get("file") as Blob; + uploadedBytes = new Uint8Array(await file.arrayBuffer()); + return { ok: true }; + }); + + const result = await uploadStagedPhotos("task-id", [ + { + id: "photo-id", + uri: "file:///photo.png", + name: "photo.png", + mimeType: "image/png", + size: mocks.photoBytes.byteLength, + }, + ]); + + expect(uploadedBytes).toEqual(mocks.photoBytes); + expect(mocks.expoFetch).toHaveBeenCalledWith( + "https://uploads.example.com", + expect.objectContaining({ method: "POST" }), + ); + expect(result).toEqual(["artifact-id"]); + }); +}); diff --git a/products/desktop/apps/mobilehog/src/lib/attachments.ts b/products/desktop/apps/mobilehog/src/lib/attachments.ts index de400f062dc7..8eff4314d2e9 100644 --- a/products/desktop/apps/mobilehog/src/lib/attachments.ts +++ b/products/desktop/apps/mobilehog/src/lib/attachments.ts @@ -2,6 +2,8 @@ import type { PreparedTaskArtifactUpload, TaskArtifactUploadRequest, } from "@posthog/api-client/posthog-client"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; import * as ImagePicker from "expo-image-picker"; import { getClient } from "@/lib/client"; @@ -51,7 +53,6 @@ function uploadRequests(photos: Photo[]): TaskArtifactUploadRequest[] { })); } -// Streams each file straight from disk to object storage; nothing is base64 encoded. async function postFiles( photos: Photo[], prepared: PreparedTaskArtifactUpload[], @@ -65,11 +66,10 @@ async function postFiles( )) { form.append(key, value); } - form.append("file", { - uri: photo.uri, - name: photo.name, - type: photo.mimeType, - } as unknown as Blob); + // Expo fetch rejects React Native's legacy { uri, name, type } part. + // File supplies the bytes() contract that its multipart encoder requires; + // React Native's FormData type does not yet include Expo's File support. + form.append("file", new File(photo.uri) as unknown as Blob); const response = await fetch(artifact.presigned_post.url, { method: "POST", body: form, diff --git a/products/desktop/apps/mobilehog/src/lib/queries.ts b/products/desktop/apps/mobilehog/src/lib/queries.ts index 2522ad1a60b3..9910895055bd 100644 --- a/products/desktop/apps/mobilehog/src/lib/queries.ts +++ b/products/desktop/apps/mobilehog/src/lib/queries.ts @@ -44,13 +44,22 @@ export function useTaskPages(search: string, archived: boolean) { const scope = archived ? "archived" : "active"; return useInfiniteQuery({ queryKey: search - ? [...keys.tasks, "pages", scope, "search", search] - : [...keys.tasks, "pages", scope], + ? [ + ...keys.tasks, + "created-by", + session?.userId, + "pages", + scope, + "search", + search, + ] + : [...keys.tasks, "created-by", session?.userId, "pages", scope], initialPageParam: 0, queryFn: async ({ pageParam }) => { const page = await getClient().getTasksPage({ basic: true, archived, + createdBy: session?.userId, search: search || undefined, ordering: "-last_activity_at", limit: PAGE_SIZE, @@ -69,11 +78,14 @@ export function useTaskPages(search: string, archived: boolean) { export function useTasks(search = "") { const session = useAuth((s) => s.session); return useQuery({ - queryKey: search ? [...keys.tasks, "search", search] : keys.tasks, + queryKey: search + ? [...keys.tasks, "created-by", session?.userId, "search", search] + : [...keys.tasks, "created-by", session?.userId], queryFn: async () => visibleTasks( await getClient().getTasks({ basic: true, + createdBy: session?.userId, search: search || undefined, }), ), diff --git a/products/desktop/apps/mobilehog/vitest.config.ts b/products/desktop/apps/mobilehog/vitest.config.ts new file mode 100644 index 000000000000..efc3e2036956 --- /dev/null +++ b/products/desktop/apps/mobilehog/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; +import { trunkTestOptions } from "../../vitest.config.base"; + +export default defineConfig({ + test: { + ...trunkTestOptions, + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/products/desktop/pnpm-lock.yaml b/products/desktop/pnpm-lock.yaml index 09704342729b..a58fbfb2c16d 100644 --- a/products/desktop/pnpm-lock.yaml +++ b/products/desktop/pnpm-lock.yaml @@ -695,6 +695,9 @@ importers: expo-device: specifier: ~57.0.2 version: 57.0.2(expo@57.0.23) + expo-file-system: + specifier: ~57.0.7 + version: 57.0.7(expo@57.0.23)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(react@19.2.6)) expo-font: specifier: ~57.0.4 version: 57.0.4(expo@57.0.23)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(react@19.2.6))(react@19.2.6) @@ -789,6 +792,9 @@ importers: typescript: specifier: ~5.9.2 version: 5.9.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.2.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) apps/web: dependencies: