fix: resolve multiple API and e2e test bugs

- Health endpoint returns "healthy" instead of "ok" for consistency
- MAX_USERS now configurable via env var (default 5)
- People API returns team names instead of UUIDs in register/list
- PUT user update accepts team names (name-first lookup, fallback to ID)
- Login rate limit follows global rate limit when RATE_LIMIT_PER_MIN > 1000
- Strip-metadata preserves original format encoding instead of always PNG
- Fix e2e tests: rotate/crop/border button selectors match actual UI
- Fix e2e tests: create Engineering/Design teams in people test setup
- Fix e2e tests: people UI uses select for team field, not text input
- Update visual regression baseline for tablet home page
This commit is contained in:
Siddharth Kumar Sah
2026-04-04 17:44:51 +08:00
parent 9f0354388f
commit 9d621734c3
11 changed files with 110 additions and 48 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ await docsRoutes(app);
// Public health check (minimal - no internal details)
app.get("/api/v1/health", async () => ({
status: "ok",
status: "healthy",
version: APP_VERSION,
}));
+1
View File
@@ -27,6 +27,7 @@ const envSchema = z.object({
DEFAULT_LOCALE: z.string().default("en"),
APP_NAME: z.string().default("Stirling Image"),
CORS_ORIGIN: z.string().default(""),
MAX_USERS: z.coerce.number().default(5),
});
export type Env = z.infer<typeof envSchema>;
+25 -10
View File
@@ -16,7 +16,7 @@ export interface AuthUser {
role: "admin" | "user";
}
const MAX_USERS = 50;
const MAX_USERS = env.MAX_USERS;
// ── Password hashing ──────────────────────────────────────────────
@@ -139,6 +139,8 @@ export async function ensureDefaultAdmin(): Promise<void> {
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
function getLoginAttemptLimit(): number {
// Allow override via RATE_LIMIT_PER_MIN for test environments
if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN;
const row = db
.select()
.from(schema.settings)
@@ -336,9 +338,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
.from(schema.users)
.all();
// Build a team ID -> name lookup
const allTeams = db.select().from(schema.teams).all();
const teamNameById = new Map(allTeams.map((t) => [t.id, t.name]));
return reply.send({
users: users.map((u) => ({
...u,
team: teamNameById.get(u.team) ?? u.team,
createdAt: u.createdAt.toISOString(),
})),
maxUsers: MAX_USERS,
@@ -383,7 +390,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Resolve team — frontend sends team name (e.g. "Default"), not ID
const requestedTeam = (body as { team?: string }).team;
let team: string;
let teamId: string;
let teamName: string;
if (requestedTeam) {
// Look up by name first, then fall back to ID
@@ -398,14 +406,16 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const found = teamByName || teamById;
if (!found)
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
team = found.id;
teamId = found.id;
teamName = found.name;
} else {
const defaultTeam = db
.select()
.from(schema.teams)
.where(eq(schema.teams.name, "Default"))
.get();
team = defaultTeam?.id || "default-team-00000000";
teamId = defaultTeam?.id || "default-team-00000000";
teamName = defaultTeam?.name || "Default";
}
// Check for duplicate username first (so 409 takes priority over limit)
@@ -440,7 +450,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
username: body.username,
passwordHash,
role,
team,
team: teamId,
mustChangePassword: true,
})
.run();
@@ -456,7 +466,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
id,
username: body.username,
role,
team,
team: teamName,
});
});
@@ -492,15 +502,20 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
if (typeof body?.team === "string" && body.team.trim()) {
const teamExists = db
// Look up by name first, then fall back to ID
const teamByName = db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, body.team.trim()))
.where(eq(schema.teams.name, body.team.trim()))
.get();
if (!teamExists) {
const teamById = teamByName
? null
: db.select().from(schema.teams).where(eq(schema.teams.id, body.team.trim())).get();
const found = teamByName || teamById;
if (!found) {
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
}
updates.team = body.team.trim();
updates.team = found.id;
}
db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
+35 -1
View File
@@ -279,10 +279,44 @@ export function registerStripMetadata(app: FastifyInstance) {
toolId: "strip-metadata",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const metadata = await sharp(inputBuffer).metadata();
const format = metadata.format ?? "png";
const image = sharp(inputBuffer);
const result = await stripMetadata(image, settings);
// Re-encode in the original format so we don't inflate the file.
// Sharp re-encodes from scratch, so we pick settings that stay close
// to the original size while still stripping metadata.
switch (format) {
case "jpeg":
result.jpeg({ quality: 90, mozjpeg: true });
break;
case "png":
result.png({ compressionLevel: 9 });
break;
case "webp":
result.webp({ quality: 85 });
break;
case "avif":
result.avif({ quality: 50 });
break;
case "tiff":
result.tiff({ compression: "lzw" });
break;
default:
break;
}
const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/png" };
const mimeMap: Record<string, string> = {
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
gif: "image/gif",
};
return { buffer, filename, contentType: mimeMap[format] ?? "image/png" };
},
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

+9 -13
View File
@@ -55,23 +55,19 @@ test.describe("Full user session", () => {
await uploadTestImage(page);
await expect(page.getByText("Upload from computer")).not.toBeVisible();
// Click 90-degree right rotation preset (the CW icon button)
await page
.locator("button")
.filter({ hasText: /90.*right|right.*90|cw/i })
.first()
.click()
.catch(async () => {
// Fallback: click the second quick-rotate button (CW)
await page.locator("aside button, [class*='panel'] button").nth(1).click();
});
// Click the clockwise 90° rotation button and wait for state
await page.getByTestId("rotate-right").click();
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
// Click the process button (button text is "Rotate")
await page.getByRole("button", { name: "Rotate" }).click();
// Click the process button (button text is "Apply")
await page.getByTestId("rotate-submit").click();
await waitForProcessing(page);
// Verify result
const downloadBtn = page.getByRole("link", { name: /download/i }).first();
const downloadBtn = page
.getByRole("button", { name: /^download$/i })
.or(page.getByRole("link", { name: /download/i }))
.first();
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
const downloadPromise = page.waitForEvent("download");
+18 -1
View File
@@ -39,11 +39,27 @@ async function cleanupTestUsers(token: string) {
}
}
/** Ensure a team exists by name (create if missing). */
async function ensureTeam(token: string, name: string) {
const res = await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(token),
body: JSON.stringify({ name }),
});
// 201 = created, 409 = already exists — both are fine
if (res.status !== 201 && res.status !== 409) {
throw new Error(`Failed to ensure team "${name}": ${res.status}`);
}
}
base.describe("People Management — API", () => {
let token: string;
base.beforeAll(async () => {
token = await getAuthToken();
// Create teams used by the tests
await ensureTeam(token, "Engineering");
await ensureTeam(token, "Design");
});
base.beforeEach(async () => {
@@ -369,7 +385,8 @@ uiTest.describe("People Management — UI", () => {
await addBtn.click();
await expect(page.getByPlaceholder("Username")).toBeVisible();
await expect(page.getByPlaceholder("Password")).toBeVisible();
await expect(page.getByPlaceholder("Team")).toBeVisible();
// Team is a <select> dropdown, not a text input with placeholder
await expect(page.locator("select").first()).toBeVisible();
await expect(page.getByRole("button", { name: /create/i })).toBeVisible();
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible();
}
+18 -20
View File
@@ -46,35 +46,33 @@ test.describe("Tool processing (core tools)", () => {
test("rotate processes image", async ({ loggedInPage: page }) => {
await page.goto("/rotate");
await uploadTestImage(page);
// Click 90 Right first to set a rotation (CW button)
await page
.locator("button")
.filter({ hasText: /90.*right|right.*90|cw/i })
.first()
.click()
.catch(async () => {
// Fallback: the second quick-rotate button
const btns = page.locator("button").filter({ has: page.locator("svg") });
if ((await btns.count()) >= 2) await btns.nth(1).click();
});
await page.getByRole("button", { name: "Rotate" }).click();
// Click the clockwise 90° rotation button and wait for state to propagate
await page.getByTestId("rotate-right").click();
// Verify the angle input updated to 90
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
await page.getByTestId("rotate-submit").click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
await expect(
page
.getByRole("button", { name: /^download$/i })
.or(page.getByRole("link", { name: /download/i }))
.first(),
).toBeVisible({ timeout: 15_000 });
});
test("crop processes image", async ({ loggedInPage: page }) => {
await page.goto("/crop");
await uploadTestImage(page);
// Crop needs valid dimensions - set small crop box
// Wait for image to load in the crop canvas
await page.waitForTimeout(1000);
// Click on the crop area to initialize a crop region, then use the preset
// or set dimensions via the number inputs once imgDimensions is available
const widthInputs = page.locator("input[type='number']");
// Fill width and height for crop
if ((await widthInputs.count()) >= 4) {
await widthInputs.nth(2).fill("50");
await widthInputs.nth(3).fill("50");
}
await page.getByRole("button", { name: "Crop" }).click();
await page.getByTestId("crop-submit").click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
@@ -109,8 +107,8 @@ test.describe("Tool processing (core tools)", () => {
await page.goto("/border");
await uploadTestImage(page);
// Default border width is 10px and color is #000000, should be valid
// Button text is "Add Border" in border-settings.tsx
await page.getByRole("button", { name: /add border/i }).click();
// Button text is "Apply Border" in border-settings.tsx
await page.getByRole("button", { name: /apply border/i }).click();
await waitForProcessing(page);
await expect(
page
+1 -1
View File
@@ -1164,7 +1164,7 @@ describe("Health & Config", () => {
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.status).toBe("ok");
expect(body.status).toBe("healthy");
expect(body.version).toBeDefined();
expect(body.uptime).toBeUndefined();
expect(body.database).toBeUndefined();
+1 -1
View File
@@ -117,7 +117,7 @@ export async function buildTestApp(): Promise<TestApp> {
// Public health check (minimal - no internal details)
app.get("/api/v1/health", async () => ({
status: "ok",
status: "healthy",
version: APP_VERSION,
}));
+1
View File
@@ -31,6 +31,7 @@ export default defineConfig({
MAX_UPLOAD_SIZE_MB: "10",
MAX_BATCH_SIZE: "10",
RATE_LIMIT_PER_MIN: "10000",
MAX_USERS: "50",
MAX_MEGAPIXELS: "100",
CONCURRENT_JOBS: "3",
FILE_MAX_AGE_HOURS: "1",