mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
fab172b782
* feat(analytics): emit file_upload_result from all three upload entries `file_upload_result` was wired only on the Design Files Upload button in FileWorkspace. The chat composer paperclip (project page) and the home hero composer paperclip uploaded files silently — PostHog dashboards saw upload activity from one of three real entry points, so per-surface funnels were invisible and totals undercounted. Three problems are fixed together: 1. `FileUploadResultProps` hard-coded `page_name: 'file_manager' / area: 'file_manager'`, which prevented the other two surfaces from type-checking. Widened to a discriminated union over the three v2 doc surfaces (`file_manager` / `chat_panel` chat_composer / `home` chat_composer). 2. `HomeChatComposerClickProps.element` was missing `'attachment'`, so the home composer paperclip had no usable click value even if we wanted to instrument it. Added the literal, mirroring the chat_panel composer. 3. Three call sites for `file_upload_result` would duplicate the per-file mime + total-bytes cohort math. Extracted to `apps/web/src/analytics/upload-tracking.ts#deriveUploadCohort` so FileWorkspace, ChatComposer, and the App.tsx Home submit path all compute the same `file_count` / `file_type` / `file_size_bucket` triplet. FileWorkspace's inline math is replaced with the shared helper to prevent drift. Call-site wiring: - HomeHero attach button: `ui_click` (`element='attachment'`) at click time. The actual upload is deferred to submit, so the `file_upload_result` for this surface fires from App.tsx after `uploadProjectFiles` resolves. - ChatComposer.uploadFiles: `file_upload_result` on success / failed / throw branches; existing `ui_click` (`element='attachment'`) at the paperclip stays as-is. - FileWorkspace.uploadFiles: refactored to use `deriveUploadCohort`; behavior unchanged. * test(analytics): cover deriveUploadCohort matrix Reviewer flagged that deriveUploadCohort silently fans out to three upload entry points (file_manager / chat_panel / home) but has no focused coverage, so a regression in zip detection, mixed-type collapsing, or the 1/10/100 MB thresholds would skew analytics without breaking any visible UI behavior. Adds homogeneous-image, zip-by-mime, zip-by-extension, mixed-type, empty-batch, bucket-boundary (1/10/100 MB), and defensive empty-mime cases.
95 lines
2.9 KiB
TypeScript
95 lines
2.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { deriveUploadCohort } from '../../src/analytics/upload-tracking';
|
|
|
|
// `deriveUploadCohort` only reads `name`, `type`, `size` — keep the
|
|
// fixture lightweight so 100 MB boundary cases don't allocate real bytes.
|
|
function makeFile(name: string, type: string, size: number): File {
|
|
return { name, type, size } as unknown as File;
|
|
}
|
|
|
|
describe('deriveUploadCohort', () => {
|
|
it('classifies a homogeneous image batch as image', () => {
|
|
const cohort = deriveUploadCohort([
|
|
makeFile('a.png', 'image/png', 1024),
|
|
makeFile('b.jpg', 'image/jpeg', 2048),
|
|
]);
|
|
expect(cohort).toEqual({
|
|
file_count: 2,
|
|
file_type: 'image',
|
|
file_size_bucket: '0_1mb',
|
|
});
|
|
});
|
|
|
|
it('detects zip by MIME', () => {
|
|
const cohort = deriveUploadCohort([
|
|
makeFile('bundle.zip', 'application/zip', 500),
|
|
]);
|
|
expect(cohort.file_type).toBe('zip');
|
|
});
|
|
|
|
it('detects zip by .zip extension when MIME is missing', () => {
|
|
const cohort = deriveUploadCohort([
|
|
makeFile('bundle.ZIP', '', 500),
|
|
makeFile('also.zip', 'application/octet-stream', 500),
|
|
]);
|
|
expect(cohort.file_type).toBe('zip');
|
|
});
|
|
|
|
it('collapses mixed-type batches to other', () => {
|
|
const cohort = deriveUploadCohort([
|
|
makeFile('a.png', 'image/png', 100),
|
|
makeFile('b.pdf', 'application/pdf', 100),
|
|
]);
|
|
expect(cohort.file_type).toBe('other');
|
|
});
|
|
|
|
it('returns other for an empty batch', () => {
|
|
expect(deriveUploadCohort([])).toEqual({
|
|
file_count: 0,
|
|
file_type: 'other',
|
|
file_size_bucket: '0_1mb',
|
|
});
|
|
});
|
|
|
|
it('buckets total bytes across the 1/10/100 MB thresholds', () => {
|
|
const MB = 1024 * 1024;
|
|
const cases: Array<{ total: number; bucket: string }> = [
|
|
{ total: MB - 1, bucket: '0_1mb' },
|
|
{ total: MB, bucket: '1_10mb' },
|
|
{ total: 10 * MB - 1, bucket: '1_10mb' },
|
|
{ total: 10 * MB, bucket: '10_100mb' },
|
|
{ total: 100 * MB - 1, bucket: '10_100mb' },
|
|
{ total: 100 * MB, bucket: '100mb_plus' },
|
|
];
|
|
for (const { total, bucket } of cases) {
|
|
const cohort = deriveUploadCohort([
|
|
makeFile('blob.bin', 'application/octet-stream', total),
|
|
]);
|
|
expect(cohort.file_size_bucket, `total=${total}`).toBe(bucket);
|
|
}
|
|
});
|
|
|
|
it('sums sizes across multiple files for the bucket', () => {
|
|
const MB = 1024 * 1024;
|
|
const cohort = deriveUploadCohort([
|
|
makeFile('a.png', 'image/png', 0.6 * MB),
|
|
makeFile('b.png', 'image/png', 0.6 * MB),
|
|
]);
|
|
expect(cohort.file_size_bucket).toBe('1_10mb');
|
|
});
|
|
|
|
it('treats missing size/type defensively', () => {
|
|
const cohort = deriveUploadCohort([
|
|
{ name: 'x', type: '', size: 0 } as unknown as File,
|
|
]);
|
|
expect(cohort).toEqual({
|
|
file_count: 1,
|
|
file_type: 'other',
|
|
file_size_bucket: '0_1mb',
|
|
});
|
|
});
|
|
});
|