mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
+74
-46
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user