Files
puter/src/backend/controllers/profile/ProfileController.test.ts
T
Daniel Salazar 9ff224e22f
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
perf(backend): cut sequential round trips on auth, fs, driver, event and site paths (#4259)
Pipeline
- CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s.
- `req.ua` is parsed on first read instead of on every request.
- JSON `rawBody` reuses the parsed buffer instead of copying it.
- `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call.
- SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and
  waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under
  load is unchanged.

Auth and permissions
- Token-to-actor auth starts the user, session and app lookups together and checks them in the
  same order: fewer sequential Redis round trips per authed request.
- get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select
  after its own insert and doesn't wait on the cache write for replica rows.
- getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer
  hands out a session that was never written.
- whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged.
- OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight
  per URL, and the Apple key is parsed once.
- gui-token and sync-cookie use `req.actor.user` instead of re-reading the user.
- listSessions batches app lookups. App icon probes run together, and the original-icon probe
  skips the lineage fallback.
- Default-granted permissions are answered from config before the scan cache, and exploded
  options are deduped: one less Redis read per /drivers/call.
- ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node
  first, then the ancestors concurrently on a miss.

Filesystem
- Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about
  1.3 s to about 40 ms per 100 new files.
- MySQL single-file inserts return the row built from the written values, falling back to the
  primary read: one less primary round trip per new file.
- Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row
  fallback.
- storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries
  `userUuid`: quota checks and /df no longer serialize the listener reads.
- readdir cache backfill is one pipeline instead of 3xN SETEX.
- Profile reads skip the lineage fallback; the own profile is read alongside the user lookup.
- Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently.
- Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream
  at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream
  upstream error.
- mkdir looks up target and parent together.
- Upload completion reuses the session from the access check (status is still re-read before
  completing), and multipart signing reads it once.
- Moving a shared file to Trash skips two lookups that always returned the entry itself.

Drivers and events
- Subscription resolvers run concurrently and are folded in registration order.
- SubdomainDriver and WorkerDriver overlap independent lookups.
- The events "anyone subscribed?" check is one Redis pipeline when the region is warm.
- Handler publish overlaps the count with the size check.
- Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays
  cross-region invalidations to the others.

Sites, share and AI
- Hosted sites resolve the owner first, then the app and root together. Site config loads only
  on 404s. The hosted-app query selects only the columns it uses and moves into AppStore.
- shareFlags asks about the plan only when an item is shared by link only.
- Share listings run the count, teams and plan reads alongside their page query.
- Claude attachment cleanup no longer blocks the response.
- New util `awaitLater` for promises started early and awaited later.
2026-10-17 12:03:35 -07:00

141 lines
4.9 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import type { PuterServer } from '../../server.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { setupTestServer } from '../../testUtil.js';
import type { ProfileController } from './ProfileController.js';
// GET /profile without a username: the caller's own profile, read alongside
// their account row.
describe('ProfileController own profile', () => {
let server: PuterServer;
let controller: ProfileController;
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.profile as unknown as ProfileController;
}, 120_000);
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
const makeUser = async (): Promise<UserRow> => {
const username = `pc-${Math.random().toString(36).slice(2, 10)}`;
return server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
requires_email_confirmation: false,
} as Parameters<typeof server.stores.user.create>[0]);
};
const getOwn = async (uuid: string) => {
let body: unknown;
const res = {
json: (value: unknown) => {
body = value;
},
} as unknown as Response;
await controller.get(
{ query: {}, actor: { user: { uuid } } } as unknown as Request,
res,
);
return body;
};
const isProfilePath = (path: unknown) =>
String(path).startsWith('/system/profiles/');
it('starts reading the profile before the account lookup returns', async () => {
const user = await makeUser();
await server.services.profile.updateProfile(user, { bio: 'hi' });
const order: string[] = [];
const getByUuid = server.stores.user.getByUuid.bind(server.stores.user);
vi.spyOn(server.stores.user, 'getByUuid').mockImplementation(
async (...args: Parameters<typeof getByUuid>) => {
await new Promise((resolve) => setTimeout(resolve, 10));
order.push('account');
return getByUuid(...args);
},
);
const getEntryByPath = server.stores.fsEntry.getEntryByPath.bind(
server.stores.fsEntry,
);
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockImplementation(
async (...args: Parameters<typeof getEntryByPath>) => {
if (isProfilePath(args[0])) order.push('profile');
return getEntryByPath(...args);
},
);
await expect(getOwn(user.uuid)).resolves.toEqual({
picture: null,
displayName: null,
bio: 'hi',
});
expect(order).toEqual(['profile', 'account']);
});
it('404s a caller with no account, whatever the profile read did', async () => {
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
new Error('profile read failed'),
);
await expect(getOwn(uuidv4())).rejects.toMatchObject({
statusCode: 404,
});
});
it('surfaces the account lookup failure before the profile read failure', async () => {
vi.spyOn(server.stores.user, 'getByUuid').mockRejectedValue(
new Error('account lookup failed'),
);
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
new Error('profile read failed'),
);
await expect(getOwn(uuidv4())).rejects.toThrow('account lookup failed');
});
it('surfaces a failed profile read for an existing account', async () => {
const user = await makeUser();
vi.spyOn(server.stores.fsEntry, 'getEntryByPath').mockRejectedValue(
new Error('profile read failed'),
);
await expect(getOwn(user.uuid)).rejects.toThrow('profile read failed');
});
});