Merge pull request #43 from yusufipk/worktree-writing-tests

test: add unit, API, component and end-to-end test suites
This commit is contained in:
Yusuf İpek
2026-07-26 11:06:24 +03:00
committed by GitHub
141 changed files with 44636 additions and 229 deletions
+74 -46
View File
@@ -215,7 +215,69 @@ export type EnrichedProjectForAccess = {
};
/**
* Pure access computation — no DB queries.
* The project permission formulas, in one place.
*
* Two functions resolve the same six inputs by different routes:
* `computeProjectAccess` reads them off a project that was fetched with
* `projectAccessInclude()`, and `checkProjectAccess` queries for each relation.
* They then have to agree on what those inputs mean. Both used to carry a
* verbatim copy of the three formulas below, which is a silent-divergence
* hazard rather than a style complaint: change an authorization rule in one
* copy and not the other and a page renders for somebody the API would refuse.
*/
function resolveProjectPermissions(input: {
isOwner: boolean;
isPublic: boolean;
isProjectMember: boolean;
isProjectAdmin: boolean;
workspaceRole: WorkspaceMemberRole | 'OWNER' | null;
ownerBillingActive: boolean;
}) {
const { isOwner, isPublic, isProjectMember, isProjectAdmin, workspaceRole, ownerBillingActive } =
input;
const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
return {
isOwner,
isProjectMember,
isProjectAdmin,
isWorkspaceMember,
isWorkspaceAdmin,
hasAccess: ownerBillingActive && (isOwner || isProjectMember || isPublic || isWorkspaceMember),
canEdit: ownerBillingActive && (isOwner || isProjectAdmin || isWorkspaceAdmin),
canDelete: ownerBillingActive && (isOwner || workspaceRole === 'OWNER'),
ownerBillingActive,
};
}
/**
* The workspace permission formulas. Only one caller today, but it is kept
* beside its project twin and exported so it can be tested directly rather
* than only through whichever route happens to exercise it.
*/
export function resolveWorkspacePermissions(input: {
isOwner: boolean;
isMember: boolean;
isAdmin: boolean;
ownerBillingActive: boolean;
}) {
const { isOwner, isMember, isAdmin, ownerBillingActive } = input;
return {
isOwner,
isMember,
isAdmin,
hasAccess: ownerBillingActive && (isOwner || isMember),
canEdit: ownerBillingActive && (isOwner || isAdmin),
canDelete: ownerBillingActive && isOwner,
ownerBillingActive,
};
}
/**
* Pure access computation, no DB queries.
* Use after fetching a project with `projectAccessInclude(userId)`.
*/
export function computeProjectAccess(
@@ -241,25 +303,14 @@ export function computeProjectAccess(
if (wsMember) workspaceRole = wsMember.role;
}
const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const hasAccess =
workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
return {
return resolveProjectPermissions({
isOwner,
isPublic,
isProjectMember,
isProjectAdmin,
isWorkspaceMember,
isWorkspaceAdmin,
hasAccess,
canEdit,
canDelete,
workspaceRole,
ownerBillingActive: workspaceOwnerBillingAccess,
};
});
}
// Helper to check project access including workspace membership
@@ -284,8 +335,8 @@ export async function checkProjectAccess(
// The workspace role decides `canEdit`/`isWorkspaceMember`, not just whether the viewer
// gets in at all, so it has to be resolved for every signed-in non-owner. Skipping it
// once access was already granted some other way (public project, or an existing project
// membership) silently downgraded workspace admins to read-only on `intent: 'view'`
// the intent pages and GET routes use to decide which actions to render.
// membership) silently downgraded workspace admins to read-only on `intent: 'view'`,
// the intent that pages and GET routes use to decide which actions to render.
// Owners pass every check on their own; resolve their role only when they mutate.
const shouldLoadWorkspaceRole = !!userId && (!isOwner || intent !== 'view');
@@ -338,25 +389,14 @@ export async function checkProjectAccess(
});
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
}
const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const hasAccess =
workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
return {
return resolveProjectPermissions({
isOwner,
isPublic,
isProjectMember,
isProjectAdmin,
isWorkspaceMember,
isWorkspaceAdmin,
hasAccess,
canEdit,
canDelete,
workspaceRole,
ownerBillingActive: workspaceOwnerBillingAccess,
};
});
}
// Helper to check workspace access
@@ -386,17 +426,5 @@ export async function checkWorkspaceAccess(
});
const ownerBillingActive = owner ? hasBillingAccess(owner) : false;
const hasAccess = ownerBillingActive && (isOwner || isMember);
const canEdit = ownerBillingActive && (isOwner || isAdmin);
const canDelete = ownerBillingActive && isOwner;
return {
isOwner,
isMember,
isAdmin,
hasAccess,
canEdit,
canDelete,
ownerBillingActive,
};
return resolveWorkspacePermissions({ isOwner, isMember, isAdmin, ownerBillingActive });
}
+12 -9
View File
@@ -1,4 +1,11 @@
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
import {
getMultipartProgressPercent,
getPartByteRange,
getRetryDelayMs,
getUploadProgressPercent,
PART_RETRY_DELAYS_MS,
} from '@/lib/client/upload-chunking';
export type R2MultipartPart = { partNumber: number; url: string };
@@ -21,8 +28,6 @@ export type R2VideoInitResponse = {
multipart: R2MultipartInit | null;
};
const PART_RETRY_DELAYS = [0, 2000, 5000, 10000];
export type R2VideoUploadResult = R2VideoInitResponse & {
duration: number | null;
thumbnailUrl: string | null;
@@ -43,7 +48,7 @@ function uploadBytesWithProgress(
xhr.upload.onprogress = (event) => {
if (!onProgress || !event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
onProgress(getUploadProgressPercent(event.loaded, event.total));
};
xhr.onload = () => {
@@ -117,7 +122,7 @@ async function withRetry<T>(fn: () => Promise<T>, delays: number[]): Promise<T>
let lastError: unknown;
for (let attempt = 0; attempt < delays.length; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
await new Promise((resolve) => setTimeout(resolve, getRetryDelayMs(attempt, delays)));
}
try {
return await fn();
@@ -160,16 +165,14 @@ async function uploadVideoMultipart(
const reportProgress = () => {
if (!onProgress) return;
const loaded = loadedPerPart.reduce((sum, value) => sum + value, 0);
onProgress(Math.min(100, Math.round((loaded / totalBytes) * 100)));
onProgress(getMultipartProgressPercent(loadedPerPart, totalBytes));
};
const completedParts: Array<{ partNumber: number; etag: string }> = [];
for (let index = 0; index < multipart.parts.length; index += 1) {
const part = multipart.parts[index];
const start = (part.partNumber - 1) * partSize;
const end = Math.min(start + partSize, totalBytes);
const { start, end } = getPartByteRange(part.partNumber, partSize, totalBytes);
const blob = file.slice(start, end);
const etag = await withRetry(
@@ -178,7 +181,7 @@ async function uploadVideoMultipart(
loadedPerPart[index] = loadedBytes;
reportProgress();
}),
PART_RETRY_DELAYS
PART_RETRY_DELAYS_MS
);
loadedPerPart[index] = end - start;
+70
View File
@@ -0,0 +1,70 @@
/**
* Pure arithmetic extracted from `r2-video-upload.ts`.
*
* The uploader itself is XMLHttpRequest wiring, `fetch` calls and timers, so the
* only test that can reach it is the end-to-end upload spec, and that spec only
* ever walks the happy path. The numbers below are the part of the uploader that
* is actually worth pinning down: which bytes each multipart part carries, how
* long a failed part waits before it is retried, and what percentage the UI is
* told. They live here so they can be called directly with fixed inputs.
*
* Nothing in this module touches the network, the DOM or a timer.
*/
/**
* Wait, in milliseconds, before each attempt at uploading a single multipart
* part, indexed by attempt number. Index 0 is the first try and is never waited
* on, so the schedule is really "try, then retry after 2s, 5s and 10s": four
* attempts and at most 17 seconds of backoff per part.
*/
export const PART_RETRY_DELAYS_MS = [0, 2000, 5000, 10000];
/**
* How long attempt `attempt` waits before it runs. The first attempt never
* waits, and an attempt past the end of the schedule is not one the caller
* should be making, so it waits not at all rather than for `undefined` ms.
*/
export function getRetryDelayMs(attempt: number, delays: number[] = PART_RETRY_DELAYS_MS): number {
if (attempt <= 0) return 0;
return delays[attempt] ?? 0;
}
export type PartByteRange = { start: number; end: number };
/**
* The slice of the file that a given part carries. Part numbers are 1-based
* because that is what S3 uses, and the final part is short: it stops at the end
* of the file rather than at a full part boundary.
*
* The part list comes from the server, which sized it from the same file length,
* so `partNumber` is always within range in practice. A part beyond the end of
* the file would produce `end` below `start`, which `Blob.slice` reads as an
* empty range.
*/
export function getPartByteRange(
partNumber: number,
partSizeBytes: number,
totalBytes: number
): PartByteRange {
const start = (partNumber - 1) * partSizeBytes;
const end = Math.min(start + partSizeBytes, totalBytes);
return { start, end };
}
/** Whole-percent progress for a single-request upload. */
export function getUploadProgressPercent(loadedBytes: number, totalBytes: number): number {
return Math.round((loadedBytes / totalBytes) * 100);
}
/**
* Whole-percent progress across a multipart upload, given the bytes reported so
* far for each part. Clamped at 100: parts report their own progress
* independently and a re-tried part can briefly double-count.
*/
export function getMultipartProgressPercent(
loadedBytesPerPart: number[],
totalBytes: number
): number {
const loaded = loadedBytesPerPart.reduce((sum, value) => sum + value, 0);
return Math.min(100, Math.round((loaded / totalBytes) * 100));
}