Spaces:
Running
Running
Improve backend request performance
Browse files- Dockerfile +1 -1
- PERFORMANCE.md +26 -0
- app.py +3 -4
- package.json +31 -30
- src/auth.ts +8 -2
- src/read-batches.ts +19 -0
- src/request-timing.ts +38 -0
- src/server.ts +42 -35
- src/single-flight.ts +13 -0
- src/supabase.ts +4 -0
- storage_listing.py +20 -0
- tests/read-batches.test.ts +22 -0
- tests/single-flight.test.ts +22 -0
- tests/test_storage_listing.py +42 -0
Dockerfile
CHANGED
|
@@ -17,7 +17,7 @@ COPY requirements.txt ./
|
|
| 17 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
|
| 19 |
COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
|
| 20 |
-
COPY --chown=appuser:appuser package.json app.py start.py ./
|
| 21 |
USER appuser
|
| 22 |
EXPOSE 7860
|
| 23 |
CMD ["python", "start.py"]
|
|
|
|
| 17 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
|
| 19 |
COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
|
| 20 |
+
COPY --chown=appuser:appuser package.json app.py start.py storage_listing.py ./
|
| 21 |
USER appuser
|
| 22 |
EXPOSE 7860
|
| 23 |
CMD ["python", "start.py"]
|
PERFORMANCE.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Performance checks
|
| 2 |
+
|
| 3 |
+
The API includes `Server-Timing` on JSON responses:
|
| 4 |
+
|
| 5 |
+
- `app`: server time until JSON serialization starts.
|
| 6 |
+
- `supabase`: summed response-header waits for Supabase requests started in this request, including auth. Parallel waits overlap, and shared in-flight work is counted only on the originating request.
|
| 7 |
+
|
| 8 |
+
Requests taking at least one second emit `slow_request` with a route template, status, elapsed time and upstream counts. No cookies, bodies, query parameters or user IDs are logged by this instrumentation. Streaming transfer time is not included in the JSON header.
|
| 9 |
+
|
| 10 |
+
Compare browser Network timings against these headers after deploying. High browser waiting time with low `app` time points toward transport/proxy latency; high `supabase` times identify upstream waits. Inspect authenticated list, detail, update and delete requests separately.
|
| 11 |
+
|
| 12 |
+
Read-only baseline from the development machine on 2026-10-20 (two requests per endpoint, not an authenticated benchmark):
|
| 13 |
+
|
| 14 |
+
| Endpoint | First | Second |
|
| 15 |
+
| --- | ---: | ---: |
|
| 16 |
+
| Public Space `/health` | 1006 ms | 787 ms |
|
| 17 |
+
| Public Space `/api/subscriptions` | 921 ms | 613 ms |
|
| 18 |
+
| Pages proxy `/api/subscriptions` | 1258 ms | 703 ms |
|
| 19 |
+
|
| 20 |
+
These samples include the development machine's network and do not establish a hosting bottleneck or a before/after improvement.
|
| 21 |
+
|
| 22 |
+
Changes reduce repeated profile reads after mutations, avoid writes on ordinary profile reads, coalesce concurrent authentication/role checks without caching completed permission decisions, and scope storage listings to one session. The storage listing APIs are documented at https://huggingface.co/docs/huggingface_hub/package_reference/hf_api.
|
| 23 |
+
|
| 24 |
+
The frontend keeps bounded, account-keyed summary snapshots in tab session storage for at most five minutes. Reloads display them while revalidating. Full analysis payloads are excluded; logout/account changes clear the cache. Server authorization remains required for every API request.
|
| 25 |
+
|
| 26 |
+
Remaining scaling work should follow production measurements: paginate large rosters and session lists, aggregate ratings in the database, and evaluate backend/database region placement. No production infrastructure or database changes were made here.
|
app.py
CHANGED
|
@@ -30,6 +30,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
| 30 |
from fastapi.responses import FileResponse, JSONResponse
|
| 31 |
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
| 32 |
from huggingface_hub.errors import RemoteEntryNotFoundError
|
|
|
|
| 33 |
|
| 34 |
load_dotenv(Path(__file__).with_name(".env"))
|
| 35 |
jobs = {}
|
|
@@ -862,9 +863,7 @@ def delete_session(session_id: UUID, request: Request):
|
|
| 862 |
raise HTTPException(409, "Session uses a different storage layout")
|
| 863 |
|
| 864 |
if storage_path == expected_path:
|
| 865 |
-
|
| 866 |
-
existing_files = hub().list_repo_files(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset")
|
| 867 |
-
if any(file_path.startswith(prefix) for file_path in existing_files):
|
| 868 |
hub().delete_folder(
|
| 869 |
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 870 |
path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
|
|
@@ -884,7 +883,7 @@ def results(session_id: UUID, request: Request):
|
|
| 884 |
prefix = f'{video_dir}camera_'
|
| 885 |
video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
|
| 886 |
camera_files = []
|
| 887 |
-
for repo_file in hub()
|
| 888 |
if not repo_file.startswith(prefix):
|
| 889 |
continue
|
| 890 |
filename = repo_file[len(video_dir):]
|
|
|
|
| 30 |
from fastapi.responses import FileResponse, JSONResponse
|
| 31 |
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
| 32 |
from huggingface_hub.errors import RemoteEntryNotFoundError
|
| 33 |
+
from storage_listing import session_files, session_folder_exists
|
| 34 |
|
| 35 |
load_dotenv(Path(__file__).with_name(".env"))
|
| 36 |
jobs = {}
|
|
|
|
| 863 |
raise HTTPException(409, "Session uses a different storage layout")
|
| 864 |
|
| 865 |
if storage_path == expected_path:
|
| 866 |
+
if session_folder_exists(hub(), os.environ["STORAGE_REPO_ID"], storage_path):
|
|
|
|
|
|
|
| 867 |
hub().delete_folder(
|
| 868 |
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 869 |
path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
|
|
|
|
| 883 |
prefix = f'{video_dir}camera_'
|
| 884 |
video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
|
| 885 |
camera_files = []
|
| 886 |
+
for repo_file in session_files(hub(), os.environ["STORAGE_REPO_ID"], video_dir.rstrip("/")):
|
| 887 |
if not repo_file.startswith(prefix):
|
| 888 |
continue
|
| 889 |
filename = repo_file[len(video_dir):]
|
package.json
CHANGED
|
@@ -1,30 +1,31 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "sporalize-backend",
|
| 3 |
-
"private": true,
|
| 4 |
-
"version": "1.0.0",
|
| 5 |
-
"type": "module",
|
| 6 |
-
"scripts": {
|
| 7 |
-
"
|
| 8 |
-
"
|
| 9 |
-
"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
-
"
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"express
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
"@types/
|
| 25 |
-
"@types/
|
| 26 |
-
"@types/
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
}
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sporalize-backend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"test": "tsx --test tests/*.test.ts",
|
| 8 |
+
"dev": "tsx watch src/server.ts",
|
| 9 |
+
"build": "tsc -p tsconfig.json",
|
| 10 |
+
"start": "python start.py"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@supabase/supabase-js": "^2.57.0",
|
| 14 |
+
"bcryptjs": "^3.0.2",
|
| 15 |
+
"cookie-parser": "^1.4.7",
|
| 16 |
+
"cors": "^2.8.5",
|
| 17 |
+
"dotenv": "^16.6.1",
|
| 18 |
+
"express": "^5.1.0",
|
| 19 |
+
"express-rate-limit": "^8.1.0",
|
| 20 |
+
"helmet": "^8.1.0",
|
| 21 |
+
"zod": "^4.1.5"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"@types/cookie-parser": "^1.4.9",
|
| 25 |
+
"@types/cors": "^2.8.19",
|
| 26 |
+
"@types/express": "^5.0.3",
|
| 27 |
+
"@types/node": "^22.16.5",
|
| 28 |
+
"tsx": "^4.20.5",
|
| 29 |
+
"typescript": "^5.8.3"
|
| 30 |
+
}
|
| 31 |
+
}
|
src/auth.ts
CHANGED
|
@@ -3,6 +3,12 @@ import type { Request, Response } from "express";
|
|
| 3 |
import type { Session, User } from "@supabase/supabase-js";
|
| 4 |
import { env, isProduction } from "./config.js";
|
| 5 |
import { supabaseAuth } from "./supabase.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
const accessCookie = "sp_access";
|
| 8 |
const refreshCookie = "sp_refresh";
|
|
@@ -59,7 +65,7 @@ export async function authenticatedUser(req: Request, res: Response): Promise<{
|
|
| 59 |
if (!accessToken && !refreshToken) return null;
|
| 60 |
|
| 61 |
if (accessToken) {
|
| 62 |
-
const { data, error } = await
|
| 63 |
if (data.user && !error) return { user: data.user };
|
| 64 |
}
|
| 65 |
|
|
@@ -67,7 +73,7 @@ export async function authenticatedUser(req: Request, res: Response): Promise<{
|
|
| 67 |
clearSessionCookies(res);
|
| 68 |
return null;
|
| 69 |
}
|
| 70 |
-
const { data, error } = await
|
| 71 |
if (error || !data.user || !data.session) {
|
| 72 |
clearSessionCookies(res);
|
| 73 |
return null;
|
|
|
|
| 3 |
import type { Session, User } from "@supabase/supabase-js";
|
| 4 |
import { env, isProduction } from "./config.js";
|
| 5 |
import { supabaseAuth } from "./supabase.js";
|
| 6 |
+
import { singleFlight } from "./single-flight.js";
|
| 7 |
+
|
| 8 |
+
// Several page, image and polling requests arrive together. Validate once for
|
| 9 |
+
// that burst, without extending a revoked token's lifetime through a TTL cache.
|
| 10 |
+
const verifyAccess = singleFlight((token: string) => supabaseAuth.auth.getUser(token));
|
| 11 |
+
const refreshAccess = singleFlight((token: string) => supabaseAuth.auth.refreshSession({ refresh_token: token }));
|
| 12 |
|
| 13 |
const accessCookie = "sp_access";
|
| 14 |
const refreshCookie = "sp_refresh";
|
|
|
|
| 65 |
if (!accessToken && !refreshToken) return null;
|
| 66 |
|
| 67 |
if (accessToken) {
|
| 68 |
+
const { data, error } = await verifyAccess(accessToken);
|
| 69 |
if (data.user && !error) return { user: data.user };
|
| 70 |
}
|
| 71 |
|
|
|
|
| 73 |
clearSessionCookies(res);
|
| 74 |
return null;
|
| 75 |
}
|
| 76 |
+
const { data, error } = await refreshAccess(refreshToken);
|
| 77 |
if (error || !data.user || !data.session) {
|
| 78 |
clearSessionCookies(res);
|
| 79 |
return null;
|
src/read-batches.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Bound parallel DB reads so large rosters do not create unbounded fan-out. */
|
| 2 |
+
export async function readBatches<T, R>(
|
| 3 |
+
items: readonly T[],
|
| 4 |
+
load: (batch: T[]) => PromiseLike<readonly R[]>,
|
| 5 |
+
batchSize = 100,
|
| 6 |
+
concurrency = 4,
|
| 7 |
+
): Promise<R[]> {
|
| 8 |
+
if (!Number.isInteger(batchSize) || batchSize < 1 ||
|
| 9 |
+
!Number.isInteger(concurrency) || concurrency < 1) throw new Error("Invalid batch configuration");
|
| 10 |
+
const output: R[] = [];
|
| 11 |
+
for (let offset = 0; offset < items.length; offset += batchSize * concurrency) {
|
| 12 |
+
const group: PromiseLike<readonly R[]>[] = [];
|
| 13 |
+
for (let cursor = offset; cursor < Math.min(items.length, offset + batchSize * concurrency); cursor += batchSize) {
|
| 14 |
+
group.push(load(items.slice(cursor, cursor + batchSize)));
|
| 15 |
+
}
|
| 16 |
+
for (const rows of await Promise.all(group)) output.push(...rows);
|
| 17 |
+
}
|
| 18 |
+
return output;
|
| 19 |
+
}
|
src/request-timing.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AsyncLocalStorage } from "node:async_hooks";
|
| 2 |
+
import type { RequestHandler } from "express";
|
| 3 |
+
|
| 4 |
+
const timings = new AsyncLocalStorage<{ upstreamMs: number; calls: number }>();
|
| 5 |
+
|
| 6 |
+
/** Measures remote response-header wait, without logging tokens or query data. */
|
| 7 |
+
export const timedFetch: typeof fetch = async (input, init) => {
|
| 8 |
+
const timing = timings.getStore();
|
| 9 |
+
const started = performance.now();
|
| 10 |
+
try {
|
| 11 |
+
return await fetch(input, init);
|
| 12 |
+
} finally {
|
| 13 |
+
if (timing) {
|
| 14 |
+
timing.calls += 1;
|
| 15 |
+
timing.upstreamMs += performance.now() - started;
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export const requestTiming: RequestHandler = (req, res, next) => {
|
| 21 |
+
const timing = { upstreamMs: 0, calls: 0 };
|
| 22 |
+
const started = performance.now();
|
| 23 |
+
const json = res.json.bind(res);
|
| 24 |
+
res.json = (body) => {
|
| 25 |
+
res.setHeader("Server-Timing",
|
| 26 |
+
`app;dur=${(performance.now() - started).toFixed(1)}, supabase;dur=${timing.upstreamMs.toFixed(1)};desc="${timing.calls} upstream calls"`);
|
| 27 |
+
return json(body);
|
| 28 |
+
};
|
| 29 |
+
res.once("finish", () => {
|
| 30 |
+
const duration = performance.now() - started;
|
| 31 |
+
if (duration >= 1000) console.info("slow_request", {
|
| 32 |
+
method: req.method, route: req.route?.path ?? "(middleware)",
|
| 33 |
+
status: res.statusCode, durationMs: Math.round(duration),
|
| 34 |
+
upstreamMs: Math.round(timing.upstreamMs), upstreamCalls: timing.calls,
|
| 35 |
+
});
|
| 36 |
+
});
|
| 37 |
+
timings.run(timing, next);
|
| 38 |
+
};
|
src/server.ts
CHANGED
|
@@ -16,10 +16,14 @@ import { registerProfileImageRoutes } from "./profile-images.js";
|
|
| 16 |
import { registerAdminRoutes } from "./admin.js";
|
| 17 |
import { colabWorkerRouter } from "./colab-worker.js";
|
| 18 |
import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
const app = express();
|
| 21 |
app.disable("x-powered-by");
|
| 22 |
app.set("trust proxy", env.TRUST_PROXY_HOPS);
|
|
|
|
| 23 |
app.use(helmet());
|
| 24 |
app.use(cors({
|
| 25 |
origin: env.FRONTEND_URL,
|
|
@@ -146,11 +150,11 @@ function playerSkillRatings(
|
|
| 146 |
});
|
| 147 |
}
|
| 148 |
|
| 149 |
-
export
|
| 150 |
const { data, error } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle();
|
| 151 |
if (error) throw new Error(`Unable to load account permissions: ${error.message}`);
|
| 152 |
return String(data?.account_role ?? "user").toLowerCase();
|
| 153 |
-
}
|
| 154 |
|
| 155 |
export async function userIsAdmin(userId: string) {
|
| 156 |
return await accountRoleFor(userId) === "admin";
|
|
@@ -170,16 +174,12 @@ export async function requireAdmin(req: Request, res: Response) {
|
|
| 170 |
}
|
| 171 |
|
| 172 |
async function loadActionAnnotations(actionIds: string[]) {
|
| 173 |
-
|
| 174 |
-
// Keep PostgREST query URLs small when a player has many analyzed actions.
|
| 175 |
-
for (let offset = 0; offset < actionIds.length; offset += 100) {
|
| 176 |
-
const batch = actionIds.slice(offset, offset + 100);
|
| 177 |
const { data, error } = await supabaseAdmin.from("annotations")
|
| 178 |
.select("action_id,score").in("action_id", batch);
|
| 179 |
if (error) throw new Error(`Unable to load action scores: ${error.message}`);
|
| 180 |
-
|
| 181 |
-
}
|
| 182 |
-
return annotations;
|
| 183 |
}
|
| 184 |
|
| 185 |
|
|
@@ -376,11 +376,6 @@ app.put("/api/players/:playerId", async (req, res, next) => {
|
|
| 376 |
if (height < 100 || height > 230 || weight < 20 || weight > 150) {
|
| 377 |
return res.status(400).json({ error: "Check the player's age, height and weight." });
|
| 378 |
}
|
| 379 |
-
let existingQuery = supabaseAdmin.from("players").select("player_id").eq("player_id", id.data);
|
| 380 |
-
if (!isAdmin) existingQuery = existingQuery.eq("user_id", current.user.id);
|
| 381 |
-
const { data: existing, error: readError } = await existingQuery.maybeSingle();
|
| 382 |
-
if (readError) throw new Error(`Unable to load player: ${readError.message}`);
|
| 383 |
-
if (!existing) return res.status(404).json({ error: "Player not found." });
|
| 384 |
let updateQuery = supabaseAdmin.from("players").update({
|
| 385 |
fname: input.fname,
|
| 386 |
lname: input.lname?.trim() || null,
|
|
@@ -392,9 +387,17 @@ app.put("/api/players/:playerId", async (req, res, next) => {
|
|
| 392 |
gender: input.gender,
|
| 393 |
}).eq("player_id", id.data);
|
| 394 |
if (!isAdmin) updateQuery = updateQuery.eq("user_id", current.user.id);
|
| 395 |
-
const { error } = await updateQuery
|
|
|
|
| 396 |
if (error) throw new Error(`Unable to update player: ${error.message}`);
|
| 397 |
-
return res.json({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
} catch (error) { next(error); }
|
| 399 |
});
|
| 400 |
|
|
@@ -543,11 +546,11 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 543 |
.select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
|
| 544 |
.order("fname", { ascending: true });
|
| 545 |
if (!isAdmin) overviewQuery = overviewQuery.eq("user_id", current.user.id);
|
| 546 |
-
const { data: overview, error: overviewError } = await overviewQuery;
|
| 547 |
-
if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
|
| 548 |
let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id");
|
| 549 |
if (!isAdmin) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
|
| 550 |
-
const { data: sessions, error: sessionsError } =
|
|
|
|
|
|
|
| 551 |
if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
|
| 552 |
const { data: actions, error: actionsError } = (sessions ?? []).length
|
| 553 |
? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
|
|
@@ -558,7 +561,8 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 558 |
|
| 559 |
const scoreMap = new Map(annotations.map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
|
| 560 |
const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
|
| 561 |
-
const
|
|
|
|
| 562 |
const ownScores = ownActions.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
|
| 563 |
const byAction = playerSkillRatings(ownActions, scoreMap);
|
| 564 |
const sessionPlayerById = new Map((sessions ?? []).map((row) => [row.session_id, row.player_id]));
|
|
@@ -574,10 +578,6 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 574 |
const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
|
| 575 |
return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
|
| 576 |
});
|
| 577 |
-
let peersQuery = supabaseAdmin.from("players").select("player_id");
|
| 578 |
-
if (!isAdmin) peersQuery = peersQuery.eq("user_id", current.user.id);
|
| 579 |
-
const { data: peers, error: peersError } = await peersQuery;
|
| 580 |
-
if (peersError) throw new Error(`Unable to load roster: ${peersError.message}`);
|
| 581 |
const playerScores = new Map<string, number[]>();
|
| 582 |
for (const action of actions ?? []) {
|
| 583 |
const ownerId = sessionPlayerById.get(action.session_id);
|
|
@@ -601,7 +601,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
|
|
| 601 |
createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
|
| 602 |
lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
|
| 603 |
scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
|
| 604 |
-
squadAverage, rank: rankIndex < 0 ? null : rankIndex + 1, playerCount:
|
| 605 |
});
|
| 606 |
} catch (error) { next(error); }
|
| 607 |
});
|
|
@@ -619,14 +619,16 @@ app.get("/api/sessions-overview", async (req, res, next) => {
|
|
| 619 |
const { data: sessions, error } = await sessionsQuery;
|
| 620 |
if (error) throw new Error(`Unable to load sessions: ${error.message}`);
|
| 621 |
const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
|
| 622 |
-
const { data: players, error: playersError } = playerIds.length
|
| 623 |
-
? await supabaseAdmin.from("players").select("player_id,fname,lname,gender,image_path").in("player_id", playerIds)
|
| 624 |
-
: { data: [], error: null };
|
| 625 |
-
if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
|
| 626 |
const sessionIds = (sessions ?? []).map((row) => row.session_id);
|
| 627 |
-
const { data: actions, error: actionsError } =
|
| 628 |
-
|
| 629 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
|
| 631 |
const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
|
| 632 |
const playerImages = new Map((players ?? []).map((row) => [row.player_id, { gender: row.gender, imagePath: row.image_path }]));
|
|
@@ -1032,11 +1034,16 @@ app.get("/api/auth/profile", async (req, res, next) => {
|
|
| 1032 |
try {
|
| 1033 |
const current = await authenticatedUser(req, res);
|
| 1034 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 1035 |
-
|
| 1036 |
-
const { data, error } = await supabaseAdmin.from("users")
|
| 1037 |
.select("fname,lname,email,academy,role,image_path")
|
| 1038 |
.eq("user_id", current.user.id)
|
| 1039 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1040 |
if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
|
| 1041 |
const hasPassword = userHasPassword(current.user);
|
| 1042 |
return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
|
|
|
|
| 16 |
import { registerAdminRoutes } from "./admin.js";
|
| 17 |
import { colabWorkerRouter } from "./colab-worker.js";
|
| 18 |
import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";
|
| 19 |
+
import { singleFlight } from "./single-flight.js";
|
| 20 |
+
import { requestTiming } from "./request-timing.js";
|
| 21 |
+
import { readBatches } from "./read-batches.js";
|
| 22 |
|
| 23 |
const app = express();
|
| 24 |
app.disable("x-powered-by");
|
| 25 |
app.set("trust proxy", env.TRUST_PROXY_HOPS);
|
| 26 |
+
app.use(requestTiming);
|
| 27 |
app.use(helmet());
|
| 28 |
app.use(cors({
|
| 29 |
origin: env.FRONTEND_URL,
|
|
|
|
| 150 |
});
|
| 151 |
}
|
| 152 |
|
| 153 |
+
export const accountRoleFor = singleFlight(async (userId: string) => {
|
| 154 |
const { data, error } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle();
|
| 155 |
if (error) throw new Error(`Unable to load account permissions: ${error.message}`);
|
| 156 |
return String(data?.account_role ?? "user").toLowerCase();
|
| 157 |
+
});
|
| 158 |
|
| 159 |
export async function userIsAdmin(userId: string) {
|
| 160 |
return await accountRoleFor(userId) === "admin";
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
async function loadActionAnnotations(actionIds: string[]) {
|
| 177 |
+
return readBatches<string, { action_id: string; score: number | null }>(actionIds, async (batch) => {
|
|
|
|
|
|
|
|
|
|
| 178 |
const { data, error } = await supabaseAdmin.from("annotations")
|
| 179 |
.select("action_id,score").in("action_id", batch);
|
| 180 |
if (error) throw new Error(`Unable to load action scores: ${error.message}`);
|
| 181 |
+
return data ?? [];
|
| 182 |
+
});
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
|
|
|
|
| 376 |
if (height < 100 || height > 230 || weight < 20 || weight > 150) {
|
| 377 |
return res.status(400).json({ error: "Check the player's age, height and weight." });
|
| 378 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
let updateQuery = supabaseAdmin.from("players").update({
|
| 380 |
fname: input.fname,
|
| 381 |
lname: input.lname?.trim() || null,
|
|
|
|
| 387 |
gender: input.gender,
|
| 388 |
}).eq("player_id", id.data);
|
| 389 |
if (!isAdmin) updateQuery = updateQuery.eq("user_id", current.user.id);
|
| 390 |
+
const { data, error } = await updateQuery
|
| 391 |
+
.select("player_id,fname,lname,bdate,height,weight,club,position,gender,image_path").maybeSingle();
|
| 392 |
if (error) throw new Error(`Unable to update player: ${error.message}`);
|
| 393 |
+
if (!data) return res.status(404).json({ error: "Player not found." });
|
| 394 |
+
return res.json({ ok: true, player: {
|
| 395 |
+
playerId: data.player_id, fname: data.fname ?? "", lname: data.lname ?? "",
|
| 396 |
+
name: [data.fname, data.lname].filter(Boolean).join(" "), bdate: data.bdate,
|
| 397 |
+
age: ageFromBirthday(data.bdate), heightCm: String(data.height ?? ""),
|
| 398 |
+
weightKg: String(data.weight ?? ""), club: data.club ?? "", position: data.position ?? "",
|
| 399 |
+
gender: data.gender ?? null, imagePath: data.image_path ?? null,
|
| 400 |
+
} });
|
| 401 |
} catch (error) { next(error); }
|
| 402 |
});
|
| 403 |
|
|
|
|
| 546 |
.select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
|
| 547 |
.order("fname", { ascending: true });
|
| 548 |
if (!isAdmin) overviewQuery = overviewQuery.eq("user_id", current.user.id);
|
|
|
|
|
|
|
| 549 |
let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id");
|
| 550 |
if (!isAdmin) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
|
| 551 |
+
const [{ data: overview, error: overviewError }, { data: sessions, error: sessionsError }] =
|
| 552 |
+
await Promise.all([overviewQuery, sessionsQuery]);
|
| 553 |
+
if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
|
| 554 |
if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
|
| 555 |
const { data: actions, error: actionsError } = (sessions ?? []).length
|
| 556 |
? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
|
|
|
|
| 561 |
|
| 562 |
const scoreMap = new Map(annotations.map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
|
| 563 |
const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
|
| 564 |
+
const ownSessionIds = new Set(ownSessions.map((session) => session.session_id));
|
| 565 |
+
const ownActions = (actions ?? []).filter((row) => ownSessionIds.has(row.session_id));
|
| 566 |
const ownScores = ownActions.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
|
| 567 |
const byAction = playerSkillRatings(ownActions, scoreMap);
|
| 568 |
const sessionPlayerById = new Map((sessions ?? []).map((row) => [row.session_id, row.player_id]));
|
|
|
|
| 578 |
const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
|
| 579 |
return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
|
| 580 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
const playerScores = new Map<string, number[]>();
|
| 582 |
for (const action of actions ?? []) {
|
| 583 |
const ownerId = sessionPlayerById.get(action.session_id);
|
|
|
|
| 601 |
createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
|
| 602 |
lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
|
| 603 |
scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
|
| 604 |
+
squadAverage, rank: rankIndex < 0 ? null : rankIndex + 1, playerCount: overview?.length ?? 0,
|
| 605 |
});
|
| 606 |
} catch (error) { next(error); }
|
| 607 |
});
|
|
|
|
| 619 |
const { data: sessions, error } = await sessionsQuery;
|
| 620 |
if (error) throw new Error(`Unable to load sessions: ${error.message}`);
|
| 621 |
const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
const sessionIds = (sessions ?? []).map((row) => row.session_id);
|
| 623 |
+
const [{ data: players, error: playersError }, { data: actions, error: actionsError }] = await Promise.all([
|
| 624 |
+
playerIds.length
|
| 625 |
+
? supabaseAdmin.from("players").select("player_id,fname,lname,gender,image_path").in("player_id", playerIds)
|
| 626 |
+
: Promise.resolve({ data: [], error: null }),
|
| 627 |
+
sessionIds.length
|
| 628 |
+
? supabaseAdmin.from("actions").select("session_id,type").in("session_id", sessionIds)
|
| 629 |
+
: Promise.resolve({ data: [], error: null }),
|
| 630 |
+
]);
|
| 631 |
+
if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
|
| 632 |
if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
|
| 633 |
const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
|
| 634 |
const playerImages = new Map((players ?? []).map((row) => [row.player_id, { gender: row.gender, imagePath: row.image_path }]));
|
|
|
|
| 1034 |
try {
|
| 1035 |
const current = await authenticatedUser(req, res);
|
| 1036 |
if (!current) return res.status(401).json({ error: "Authentication required." });
|
| 1037 |
+
const readProfile = () => supabaseAdmin.from("users")
|
|
|
|
| 1038 |
.select("fname,lname,email,academy,role,image_path")
|
| 1039 |
.eq("user_id", current.user.id)
|
| 1040 |
+
.maybeSingle();
|
| 1041 |
+
let { data, error } = await readProfile();
|
| 1042 |
+
// Repair an absent profile only; normal reads must not write to the DB.
|
| 1043 |
+
if (!error && !data) {
|
| 1044 |
+
await syncProfile(current.user);
|
| 1045 |
+
({ data, error } = await readProfile());
|
| 1046 |
+
}
|
| 1047 |
if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
|
| 1048 |
const hasPassword = userHasPassword(current.user);
|
| 1049 |
return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
|
src/single-flight.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Share only concurrent work. Completed values and failures are never cached. */
|
| 2 |
+
export function singleFlight<Key, Value>(load: (key: Key) => PromiseLike<Value>) {
|
| 3 |
+
const pending = new Map<Key, Promise<Value>>();
|
| 4 |
+
return (key: Key): Promise<Value> => {
|
| 5 |
+
const existing = pending.get(key);
|
| 6 |
+
if (existing) return existing;
|
| 7 |
+
const request = Promise.resolve().then(() => load(key)).finally(() => {
|
| 8 |
+
if (pending.get(key) === request) pending.delete(key);
|
| 9 |
+
});
|
| 10 |
+
pending.set(key, request);
|
| 11 |
+
return request;
|
| 12 |
+
};
|
| 13 |
+
}
|
src/supabase.ts
CHANGED
|
@@ -3,6 +3,7 @@ import bcrypt from "bcryptjs";
|
|
| 3 |
import { randomBytes } from "node:crypto";
|
| 4 |
import type { Request, Response } from "express";
|
| 5 |
import { env, isProduction } from "./config.js";
|
|
|
|
| 6 |
|
| 7 |
// Bind Google's PKCE verifier to the initiating browser, not a shared client's
|
| 8 |
// in-memory store (which concurrent logins can overwrite or a restart can lose).
|
|
@@ -10,6 +11,7 @@ export function oauthClient(req: Request, res: Response) {
|
|
| 10 |
const cookieName = "sp_oauth_verifier";
|
| 11 |
const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
|
| 12 |
return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
|
|
|
| 13 |
auth: {
|
| 14 |
autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
|
| 15 |
storage: {
|
|
@@ -26,12 +28,14 @@ export function oauthClient(req: Request, res: Response) {
|
|
| 26 |
}
|
| 27 |
|
| 28 |
export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
|
|
|
| 29 |
// OAuth returns a one-time code for the backend callback to exchange, rather
|
| 30 |
// than placing session tokens in the browser URL fragment.
|
| 31 |
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
|
| 32 |
});
|
| 33 |
|
| 34 |
export const supabaseAdmin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
|
|
|
| 35 |
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
|
| 36 |
});
|
| 37 |
|
|
|
|
| 3 |
import { randomBytes } from "node:crypto";
|
| 4 |
import type { Request, Response } from "express";
|
| 5 |
import { env, isProduction } from "./config.js";
|
| 6 |
+
import { timedFetch } from "./request-timing.js";
|
| 7 |
|
| 8 |
// Bind Google's PKCE verifier to the initiating browser, not a shared client's
|
| 9 |
// in-memory store (which concurrent logins can overwrite or a restart can lose).
|
|
|
|
| 11 |
const cookieName = "sp_oauth_verifier";
|
| 12 |
const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
|
| 13 |
return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
| 14 |
+
global: { fetch: timedFetch },
|
| 15 |
auth: {
|
| 16 |
autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
|
| 17 |
storage: {
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
| 31 |
+
global: { fetch: timedFetch },
|
| 32 |
// OAuth returns a one-time code for the backend callback to exchange, rather
|
| 33 |
// than placing session tokens in the browser URL fragment.
|
| 34 |
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
|
| 35 |
});
|
| 36 |
|
| 37 |
export const supabaseAdmin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
| 38 |
+
global: { fetch: timedFetch },
|
| 39 |
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
|
| 40 |
});
|
| 41 |
|
storage_listing.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Keep metadata requests scoped to the session being viewed."""
|
| 2 |
+
from huggingface_hub import RepoFile
|
| 3 |
+
from huggingface_hub.errors import RemoteEntryNotFoundError
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def session_files(api, repo_id, folder):
|
| 7 |
+
try:
|
| 8 |
+
return [
|
| 9 |
+
entry.path for entry in api.list_repo_tree(
|
| 10 |
+
repo_id=repo_id, repo_type="dataset", path_in_repo=folder,
|
| 11 |
+
recursive=False, expand=False,
|
| 12 |
+
) if isinstance(entry, RepoFile)
|
| 13 |
+
]
|
| 14 |
+
except RemoteEntryNotFoundError:
|
| 15 |
+
# Old sessions can have results but no source video folder.
|
| 16 |
+
return []
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def session_folder_exists(api, repo_id, folder):
|
| 20 |
+
return bool(api.get_paths_info(repo_id=repo_id, repo_type="dataset", paths=[folder]))
|
tests/read-batches.test.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from "node:assert/strict";
|
| 2 |
+
import { test } from "node:test";
|
| 3 |
+
import { readBatches } from "../src/read-batches.js";
|
| 4 |
+
|
| 5 |
+
test("caps concurrent reads, keeps result order and includes the final partial batch", async () => {
|
| 6 |
+
let active = 0;
|
| 7 |
+
let peak = 0;
|
| 8 |
+
const input = Array.from({ length: 23 }, (_, index) => index);
|
| 9 |
+
const output = await readBatches(input, async (batch) => {
|
| 10 |
+
peak = Math.max(peak, ++active);
|
| 11 |
+
await new Promise((resolve) => setTimeout(resolve, batch[0] % 2 ? 1 : 5));
|
| 12 |
+
active--;
|
| 13 |
+
return batch;
|
| 14 |
+
}, 5, 3);
|
| 15 |
+
assert.equal(peak, 3);
|
| 16 |
+
assert.deepEqual(output, input);
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
test("does not query empty input or return incomplete results after failure", async () => {
|
| 20 |
+
assert.deepEqual(await readBatches([], () => { throw new Error("should not run"); }), []);
|
| 21 |
+
await assert.rejects(readBatches([1, 2], async () => { throw new Error("DB unavailable"); }), /DB unavailable/);
|
| 22 |
+
});
|
tests/single-flight.test.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from "node:assert/strict";
|
| 2 |
+
import { test } from "node:test";
|
| 3 |
+
import { singleFlight } from "../src/single-flight.js";
|
| 4 |
+
|
| 5 |
+
test("shares concurrent validation, isolates tokens, and revalidates after completion", async () => {
|
| 6 |
+
let calls = 0;
|
| 7 |
+
const run = singleFlight(async (token: string) => { calls++; return token; });
|
| 8 |
+
const first = run("one");
|
| 9 |
+
assert.equal(first, run("one"));
|
| 10 |
+
assert.notEqual(first, run("two"));
|
| 11 |
+
assert.equal(await first, "one");
|
| 12 |
+
assert.equal(calls, 2);
|
| 13 |
+
await run("one");
|
| 14 |
+
assert.equal(calls, 3);
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
test("does not retain failed validation", async () => {
|
| 18 |
+
let calls = 0;
|
| 19 |
+
const run = singleFlight(async () => { if (++calls === 1) throw new Error("network"); return "verified"; });
|
| 20 |
+
await assert.rejects(run("token"), /network/);
|
| 21 |
+
assert.equal(await run("token"), "verified");
|
| 22 |
+
});
|
tests/test_storage_listing.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import httpx
|
| 3 |
+
from unittest.mock import Mock
|
| 4 |
+
from huggingface_hub import RepoFile, RepoFolder
|
| 5 |
+
from huggingface_hub.errors import RemoteEntryNotFoundError
|
| 6 |
+
from storage_listing import session_files, session_folder_exists
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class StorageListingTest(unittest.TestCase):
|
| 10 |
+
def test_lists_only_requested_folder_and_excludes_directories(self):
|
| 11 |
+
api = Mock()
|
| 12 |
+
api.list_repo_tree.return_value = [
|
| 13 |
+
RepoFile(path="sessions/one/videos/camera_1.mp4", size=1, oid="a"),
|
| 14 |
+
RepoFolder(path="sessions/one/videos/subdir", oid="b"),
|
| 15 |
+
]
|
| 16 |
+
self.assertEqual(session_files(api, "repo", "sessions/one/videos"),
|
| 17 |
+
["sessions/one/videos/camera_1.mp4"])
|
| 18 |
+
api.list_repo_tree.assert_called_once_with(
|
| 19 |
+
repo_id="repo", repo_type="dataset", path_in_repo="sessions/one/videos",
|
| 20 |
+
recursive=False, expand=False)
|
| 21 |
+
api.list_repo_files.assert_not_called()
|
| 22 |
+
|
| 23 |
+
def test_missing_folder_is_empty_but_other_errors_propagate(self):
|
| 24 |
+
api = Mock()
|
| 25 |
+
api.list_repo_tree.side_effect = RemoteEntryNotFoundError(
|
| 26 |
+
"missing", response=httpx.Response(404, request=httpx.Request("GET", "https://example.com/tree")))
|
| 27 |
+
self.assertEqual(session_files(api, "repo", "folder"), [])
|
| 28 |
+
api.list_repo_tree.side_effect = RuntimeError("unavailable")
|
| 29 |
+
with self.assertRaises(RuntimeError):
|
| 30 |
+
session_files(api, "repo", "folder")
|
| 31 |
+
|
| 32 |
+
def test_deletion_checks_only_the_session_path(self):
|
| 33 |
+
api = Mock()
|
| 34 |
+
api.get_paths_info.return_value = []
|
| 35 |
+
self.assertFalse(session_folder_exists(api, "repo", "sessions/one"))
|
| 36 |
+
api.get_paths_info.assert_called_once_with(
|
| 37 |
+
repo_id="repo", repo_type="dataset", paths=["sessions/one"])
|
| 38 |
+
api.list_repo_files.assert_not_called()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
unittest.main()
|