Files
webstudio/packages/project/src/db/project.test.ts
T
Oleg Isonen c389cd3bb2 fix: classify errors with structured codes and types (#6539)
Invalid or revoked share links now return an actionable 401 response
instead of an unexpected server error. Missing project and workspace
records receive distinct responses at their lookup sites. Token,
project, and workspace lookups use `.maybeSingle()` and rely on database
uniqueness. Missing projects have an explicit `ProjectNotFoundError`;
the loader no longer interprets `PGRST116`.

The API preserves `UNAUTHORIZED` and `INVALID_AUTH_TOKEN` when
authorization fails during context creation, so CLI and MCP clients can
offer relinking guidance without inspecting error text. Tokens without
API access carry `API_ACCESS_DISABLED`.

A repository audit replaces error-message matching with typed errors,
stable codes, or structured diagnostics in CLI argument/configuration
handling, session conflicts, preview cleanup, import retries, domain
unpublishing, asset validation, JSX/expression diagnostics, CSS
validation, and publish diagnostics. `AGENTS.md` now explicitly
prohibits message-based error classification and recovery.

The existing build-permission preflight now advertises structured
import-error support. Clients upload assets before importing into older
servers that do not advertise it; newer servers keep selective retries
using structured missing-file details. The internal template-validation
error class remains outside public JSX bindings and input descriptions.

Shared Builder `parseError` normalizes tRPC and local validation errors,
so UI components only add display context instead of decoding transport
payloads.

Simplification removes three redundant error classes, catch-and-ignore
handlers, an unused formatter, and nested loader error handling.
Unpublishing an already unpublished domain succeeds after authorization.
CLI configuration failures use the existing shared error handling, and
plan parsing catches only JSON parsing failures.

Validation: affected suites passed (271 tests across 8 suites in the
latest simplification checks), affected package typechecks passed, and
lint, package-boundary checks, and `git diff --check` passed. The audit
replaced synthetic parser branches and private-method assertions with
actual tRPC round trips, PostgREST responses, and public VM behavior.
Controlled regressions confirmed that removing serialized validation
details and exposing the internal JSX error class both fail the revised
tests. Astra reviewed the production changes at medium effort; its
compatibility finding and the GitHub JSX-binding comment are fixed, and
follow-up review found no remaining substantive issues.
2026-10-16 12:14:57 +02:00

120 lines
3.6 KiB
TypeScript

import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import {
createTestServer,
db,
json,
empty,
testContext,
} from "@webstudio-is/postgrest/testing";
import {
authorizeProject,
type AppContext,
} from "@webstudio-is/trpc-interface/index.server";
import { defaultPlanFeatures } from "@webstudio-is/plans";
import { create, loadById, ProjectNotFoundError } from "./project";
const server = createTestServer();
const createContext = (
planOverrides: Partial<typeof defaultPlanFeatures> = {}
): AppContext =>
({
authorization: { type: "user", userId: "owner-1" },
planFeatures: defaultPlanFeatures,
purchases: [],
trpcCache: new Map(),
domain: {},
deployment: {},
entri: {},
...testContext,
createTokenContext: () => {},
getOwnerPlanFeatures: async () => ({
...defaultPlanFeatures,
...planOverrides,
}),
}) as unknown as AppContext;
describe("create — maxProjectsAllowedPerUser guard", () => {
test("throws when owner is at the project limit", async () => {
server.use(
// Project count HEAD — owner already has 100 projects
db.head("Project", () => empty({ headers: { "Content-Range": "*/100" } }))
);
const ctx = createContext({ maxProjectsAllowedPerUser: 100 });
await expect(create({ title: "New Project" }, ctx)).rejects.toThrow(
"You've reached the project limit"
);
});
test("proceeds when owner is under the project limit", async () => {
const projectRow = {
id: "proj-new",
title: "New Project",
userId: "owner-1",
workspaceId: null,
domain: "new-project",
isDeleted: false,
};
server.use(
// Project count HEAD — owner has 99 projects, limit is 100
db.head("Project", () => empty({ headers: { "Content-Range": "*/99" } })),
// Project insert
db.post("Project", () => json([projectRow], { status: 201 })),
// Project update (assign userId)
db.patch("Project", () => json(projectRow)),
// createBuild inserts into Build table
db.post("Build", () => empty({ status: 201 }))
);
const ctx = createContext({ maxProjectsAllowedPerUser: 100 });
const result = await create({ title: "New Project" }, ctx);
expect(result).toMatchObject({ id: "proj-new", title: "New Project" });
});
});
describe("loadById", () => {
beforeEach(() => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
});
afterEach(() => vi.restoreAllMocks());
test("treats an absent project as an explicit not-found result", async () => {
server.use(db.get("Project", () => json([])));
const result = loadById("missing-project", createContext());
await expect(result).rejects.toBeInstanceOf(ProjectNotFoundError);
await expect(result).rejects.toMatchObject({
code: "NOT_FOUND",
cause: { webstudioCode: "NOT_FOUND" },
});
});
test("returns the unique project with its optional static build", async () => {
server.use(
db.get("Project", () =>
json([{ id: "project-1", latestStaticBuild: [] }])
)
);
await expect(loadById("project-1", createContext())).resolves.toMatchObject(
{ id: "project-1", latestStaticBuild: null }
);
});
test("preserves database failures instead of reporting a missing project", async () => {
server.use(
db.get("Project", () =>
json(
{ code: "08006", message: "Database unavailable" },
{ status: 503 }
)
)
);
await expect(loadById("project-1", createContext())).rejects.toMatchObject({
code: "08006",
});
});
});