From b1825fd3097818ade6e910b00c86dff60c1b2b81 Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:07:21 +0200 Subject: [PATCH] feat(admin): adjust a member's uploaded and downloaded totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can add to or remove from a member's uploaded/downloaded totals from Admin → Users, with a required reason, a preview of the resulting totals and ratio, and the history of previous corrections. Adjustments are deltas applied under a row lock, like the tracker's own traffic updates, so neither write is lost; totals never go below 0 and the logged deltas are the ones really applied. Reasons are encrypted by panic mode with the other ledgers. Co-Authored-By: Claude Opus 5.5 --- app/components/admin/UserStats.vue | 152 +++++++++++++++ app/components/admin/Users.vue | 179 ++++++++++-------- i18n/locales/de/app.json | 18 +- i18n/locales/en/app.json | 18 +- i18n/locales/es/app.json | 18 +- i18n/locales/fr/app.json | 18 +- i18n/locales/it/app.json | 18 +- i18n/locales/ja/app.json | 18 +- i18n/locales/ru/app.json | 18 +- i18n/locales/zh/app.json | 18 +- server/api/admin/panic/encrypt.post.ts | 9 + server/api/admin/panic/restore.post.ts | 9 + server/api/admin/users/[id]/stats.get.ts | 40 ++++ server/api/admin/users/[id]/stats.post.ts | 61 ++++++ .../db/migrations/0019_stat_adjustments.sql | 11 ++ server/db/schema.ts | 20 ++ shared/utils/statAdjust.ts | 42 ++++ tests/stat-adjust.test.ts | 51 +++++ 18 files changed, 628 insertions(+), 90 deletions(-) create mode 100644 app/components/admin/UserStats.vue create mode 100644 server/api/admin/users/[id]/stats.get.ts create mode 100644 server/api/admin/users/[id]/stats.post.ts create mode 100644 server/db/migrations/0019_stat_adjustments.sql create mode 100644 shared/utils/statAdjust.ts create mode 100644 tests/stat-adjust.test.ts diff --git a/app/components/admin/UserStats.vue b/app/components/admin/UserStats.vue new file mode 100644 index 00000000..aa8c4ddc --- /dev/null +++ b/app/components/admin/UserStats.vue @@ -0,0 +1,152 @@ + + + diff --git a/app/components/admin/Users.vue b/app/components/admin/Users.vue index 458eb661..9c9ed5c0 100644 --- a/app/components/admin/Users.vue +++ b/app/components/admin/Users.vue @@ -38,97 +38,110 @@
-
-

- {{ u.username }} -

-

- ID: {{ u.id.substring(0, 8) }}... -

-

- IP: {{ u.lastIp }} -

-
-
-
- - -
-
- +
+

- + IP: {{ u.lastIp }} +

-
- - -
-
- +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
([]); const roles = ref([]); +// User whose upload/download panel is open +const statsOpen = ref(null); // Load roles on mount onMounted(async () => { diff --git a/i18n/locales/de/app.json b/i18n/locales/de/app.json index e5257ac9..2005db5b 100644 --- a/i18n/locales/de/app.json +++ b/i18n/locales/de/app.json @@ -905,7 +905,23 @@ "confirmUnban": "Möchtest du die Sperre von {name} wirklich aufheben?", "banFailed": "Benutzer konnte nicht gesperrt werden", "unbanFailed": "Sperre konnte nicht aufgehoben werden", - "assignRoleFailed": "Rolle konnte nicht zugewiesen werden" + "assignRoleFailed": "Rolle konnte nicht zugewiesen werden", + "stats": { + "open": "Ratio", + "uploaded": "Hochgeladen", + "downloaded": "Heruntergeladen", + "operation": "Hinzufügen oder abziehen", + "add": "Hinzufügen", + "remove": "Abziehen", + "unit": "Einheit", + "reason": "Grund", + "reasonPlaceholder": "Im Verlauf sichtbar, z. B. Ausgleich für den Tracker-Ausfall", + "apply": "Anwenden", + "history": "Frühere Korrekturen", + "deletedAdmin": "Gelöschter Admin", + "applied": "Summen von {name} aktualisiert", + "failed": "Summen konnten nicht aktualisiert werden" + } }, "footer": { "title": "Footer-Links", diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index 56a7d3b1..c6d43b1c 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -905,7 +905,23 @@ "confirmUnban": "Are you sure you want to unban {name}?", "banFailed": "Failed to ban user", "unbanFailed": "Failed to unban user", - "assignRoleFailed": "Failed to assign role" + "assignRoleFailed": "Failed to assign role", + "stats": { + "open": "Ratio", + "uploaded": "Uploaded", + "downloaded": "Downloaded", + "operation": "Add or remove", + "add": "Add", + "remove": "Remove", + "unit": "Unit", + "reason": "Reason", + "reasonPlaceholder": "Shown in the history, e.g. compensation for the tracker outage", + "apply": "Apply", + "history": "Previous corrections", + "deletedAdmin": "Deleted admin", + "applied": "Totals of {name} updated", + "failed": "Could not update the totals" + } }, "footer": { "title": "Footer links", diff --git a/i18n/locales/es/app.json b/i18n/locales/es/app.json index 5755a48b..f561b35f 100644 --- a/i18n/locales/es/app.json +++ b/i18n/locales/es/app.json @@ -905,7 +905,23 @@ "confirmUnban": "¿Seguro que quieres desbanear a {name}?", "banFailed": "No se pudo banear al usuario", "unbanFailed": "No se pudo desbanear al usuario", - "assignRoleFailed": "No se pudo asignar el rol" + "assignRoleFailed": "No se pudo asignar el rol", + "stats": { + "open": "Ratio", + "uploaded": "Subido", + "downloaded": "Descargado", + "operation": "Añadir o quitar", + "add": "Añadir", + "remove": "Quitar", + "unit": "Unidad", + "reason": "Motivo", + "reasonPlaceholder": "Visible en el historial, p. ej. compensación por la caída del tracker", + "apply": "Aplicar", + "history": "Correcciones anteriores", + "deletedAdmin": "Admin eliminado", + "applied": "Totales de {name} actualizados", + "failed": "No se pudieron actualizar los totales" + } }, "footer": { "title": "Enlaces del pie de página", diff --git a/i18n/locales/fr/app.json b/i18n/locales/fr/app.json index 1604192e..56663501 100644 --- a/i18n/locales/fr/app.json +++ b/i18n/locales/fr/app.json @@ -905,7 +905,23 @@ "confirmUnban": "Voulez-vous vraiment débannir {name} ?", "banFailed": "Impossible de bannir l'utilisateur", "unbanFailed": "Impossible de débannir l'utilisateur", - "assignRoleFailed": "Impossible d'attribuer le rôle" + "assignRoleFailed": "Impossible d'attribuer le rôle", + "stats": { + "open": "Ratio", + "uploaded": "Upload", + "downloaded": "Download", + "operation": "Ajouter ou retirer", + "add": "Ajouter", + "remove": "Retirer", + "unit": "Unité", + "reason": "Raison", + "reasonPlaceholder": "Visible dans l'historique, ex. compensation de la panne du tracker", + "apply": "Appliquer", + "history": "Corrections précédentes", + "deletedAdmin": "Admin supprimé", + "applied": "Totaux de {name} mis à jour", + "failed": "Impossible de mettre à jour les totaux" + } }, "footer": { "title": "Liens du footer", diff --git a/i18n/locales/it/app.json b/i18n/locales/it/app.json index faf42f6c..592beaa2 100644 --- a/i18n/locales/it/app.json +++ b/i18n/locales/it/app.json @@ -905,7 +905,23 @@ "confirmUnban": "Vuoi davvero rimuovere il ban a {name}?", "banFailed": "Ban dell'utente non riuscito", "unbanFailed": "Rimozione del ban non riuscita", - "assignRoleFailed": "Assegnazione del ruolo non riuscita" + "assignRoleFailed": "Assegnazione del ruolo non riuscita", + "stats": { + "open": "Ratio", + "uploaded": "Caricato", + "downloaded": "Scaricato", + "operation": "Aggiungi o togli", + "add": "Aggiungi", + "remove": "Togli", + "unit": "Unità", + "reason": "Motivo", + "reasonPlaceholder": "Visibile nello storico, es. compensazione per il guasto del tracker", + "apply": "Applica", + "history": "Correzioni precedenti", + "deletedAdmin": "Admin eliminato", + "applied": "Totali di {name} aggiornati", + "failed": "Impossibile aggiornare i totali" + } }, "footer": { "title": "Link del footer", diff --git a/i18n/locales/ja/app.json b/i18n/locales/ja/app.json index 787c52d0..f5ac42bc 100644 --- a/i18n/locales/ja/app.json +++ b/i18n/locales/ja/app.json @@ -905,7 +905,23 @@ "confirmUnban": "{name} の BAN を解除してもよろしいですか?", "banFailed": "ユーザーの BAN に失敗しました", "unbanFailed": "ユーザーの BAN 解除に失敗しました", - "assignRoleFailed": "ロールの割り当てに失敗しました" + "assignRoleFailed": "ロールの割り当てに失敗しました", + "stats": { + "open": "レシオ", + "uploaded": "アップロード量", + "downloaded": "ダウンロード量", + "operation": "追加または削減", + "add": "追加", + "remove": "削減", + "unit": "単位", + "reason": "理由", + "reasonPlaceholder": "履歴に表示されます(例:トラッカー障害の補填)", + "apply": "適用", + "history": "過去の修正", + "deletedAdmin": "削除された管理者", + "applied": "{name} の合計を更新しました", + "failed": "合計を更新できませんでした" + } }, "footer": { "title": "フッターのリンク", diff --git a/i18n/locales/ru/app.json b/i18n/locales/ru/app.json index cbacdb0e..99ea9335 100644 --- a/i18n/locales/ru/app.json +++ b/i18n/locales/ru/app.json @@ -905,7 +905,23 @@ "confirmUnban": "Вы уверены, что хотите разбанить {name}?", "banFailed": "Не удалось забанить пользователя", "unbanFailed": "Не удалось разбанить пользователя", - "assignRoleFailed": "Не удалось назначить роль" + "assignRoleFailed": "Не удалось назначить роль", + "stats": { + "open": "Рейтинг", + "uploaded": "Отдано", + "downloaded": "Скачано", + "operation": "Добавить или убрать", + "add": "Добавить", + "remove": "Убрать", + "unit": "Единица", + "reason": "Причина", + "reasonPlaceholder": "Видна в истории, например компенсация за сбой трекера", + "apply": "Применить", + "history": "Прошлые исправления", + "deletedAdmin": "Удалённый админ", + "applied": "Итоги {name} обновлены", + "failed": "Не удалось обновить итоги" + } }, "footer": { "title": "Ссылки в подвале", diff --git a/i18n/locales/zh/app.json b/i18n/locales/zh/app.json index 33ee422b..59159ae7 100644 --- a/i18n/locales/zh/app.json +++ b/i18n/locales/zh/app.json @@ -905,7 +905,23 @@ "confirmUnban": "确定要解封 {name} 吗?", "banFailed": "封禁用户失败", "unbanFailed": "解封用户失败", - "assignRoleFailed": "分配角色失败" + "assignRoleFailed": "分配角色失败", + "stats": { + "open": "分享率", + "uploaded": "上传量", + "downloaded": "下载量", + "operation": "增加或减少", + "add": "增加", + "remove": "减少", + "unit": "单位", + "reason": "原因", + "reasonPlaceholder": "显示在历史记录中,例如 tracker 故障补偿", + "apply": "应用", + "history": "以往修正", + "deletedAdmin": "已删除的管理员", + "applied": "已更新 {name} 的总量", + "failed": "无法更新总量" + } }, "footer": { "title": "页脚链接", diff --git a/server/api/admin/panic/encrypt.post.ts b/server/api/admin/panic/encrypt.post.ts index adf40935..4cb1c633 100644 --- a/server/api/admin/panic/encrypt.post.ts +++ b/server/api/admin/panic/encrypt.post.ts @@ -13,6 +13,7 @@ import { shoutMessages, reports, bonusTransactions, + statAdjustments, bounties, } from '../../../db/schema'; import { requireAdminSession } from '../../../utils/adminAuth'; @@ -256,6 +257,14 @@ export default defineEventHandler(async (event) => { .where(eq(bonusTransactions.id, transaction.id)); } + const allAdjustments = await tx.select().from(statAdjustments); + for (const adjustment of allAdjustments) { + await tx + .update(statAdjustments) + .set({ reason: encryptField(adjustment.reason, key)! }) + .where(eq(statAdjustments.id, adjustment.id)); + } + // ===================================================================== // Save panic state // encryptionIv stays null: each value carries its own IV (iv:ct:tag). diff --git a/server/api/admin/panic/restore.post.ts b/server/api/admin/panic/restore.post.ts index 6d8b14f4..3367cb5f 100644 --- a/server/api/admin/panic/restore.post.ts +++ b/server/api/admin/panic/restore.post.ts @@ -13,6 +13,7 @@ import { shoutMessages, reports, bonusTransactions, + statAdjustments, bounties, } from '../../../db/schema'; import { protectEndpoint } from '../../../utils/rateLimit'; @@ -317,6 +318,14 @@ export default defineEventHandler(async (event) => { .where(eq(bonusTransactions.id, transaction.id)); } + const allAdjustments = await tx.select().from(statAdjustments); + for (const adjustment of allAdjustments) { + await tx + .update(statAdjustments) + .set({ reason: decryptOrKeep(adjustment.reason, key, legacyIv)! }) + .where(eq(statAdjustments.id, adjustment.id)); + } + // =================================================================== // Update panic state // =================================================================== diff --git a/server/api/admin/users/[id]/stats.get.ts b/server/api/admin/users/[id]/stats.get.ts new file mode 100644 index 00000000..27d5087b --- /dev/null +++ b/server/api/admin/users/[id]/stats.get.ts @@ -0,0 +1,40 @@ +import { desc, eq } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { db, schema } from '~~/server/db'; +import { requireAdminSession } from '~~/server/utils/adminAuth'; +import { uuidSchema, validateParam } from '~~/server/utils/schemas'; + +const admins = alias(schema.users, 'admins'); + +/** + * GET /api/admin/users/:id/stats + * Uploaded/downloaded totals and the last admin corrections (admin only) + */ +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + const userId = validateParam(event, 'id', uuidSchema); + + const [user] = await db + .select({ uploaded: schema.users.uploaded, downloaded: schema.users.downloaded }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + if (!user) throw createError({ statusCode: 404, message: 'User not found' }); + + const adjustments = await db + .select({ + id: schema.statAdjustments.id, + uploadedDelta: schema.statAdjustments.uploadedDelta, + downloadedDelta: schema.statAdjustments.downloadedDelta, + reason: schema.statAdjustments.reason, + createdAt: schema.statAdjustments.createdAt, + admin: admins.username, + }) + .from(schema.statAdjustments) + .leftJoin(admins, eq(admins.id, schema.statAdjustments.adminId)) + .where(eq(schema.statAdjustments.userId, userId)) + .orderBy(desc(schema.statAdjustments.createdAt)) + .limit(20); + + return { ...user, adjustments }; +}); diff --git a/server/api/admin/users/[id]/stats.post.ts b/server/api/admin/users/[id]/stats.post.ts new file mode 100644 index 00000000..38f182a1 --- /dev/null +++ b/server/api/admin/users/[id]/stats.post.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'crypto'; +import { eq, sql } from 'drizzle-orm'; +import { db, schema } from '~~/server/db'; +import { requireAdminSession } from '~~/server/utils/adminAuth'; +import { uuidSchema, validateBody, validateParam } from '~~/server/utils/schemas'; +import { invalidateTrackerUsers } from '~~/server/tracker/client'; +import { statAdjustmentSchema } from '~~/shared/utils/statAdjust'; + +/** + * POST /api/admin/users/:id/stats + * Add to or remove from a member's uploaded/downloaded totals, logged with + * the admin and the reason (admin only) + */ +export default defineEventHandler(async (event) => { + const session = await requireAdminSession(event); + const userId = validateParam(event, 'id', uuidSchema); + const { uploadedDelta, downloadedDelta, reason } = await validateBody(event, statAdjustmentSchema); + + const result = await db.transaction(async (tx) => { + // Lock the row: the tracker adds its traffic as deltas too, so neither + // write is lost, and the logged deltas are the ones really applied + const [before] = await tx + .select({ uploaded: schema.users.uploaded, downloaded: schema.users.downloaded }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .for('update'); + if (!before) throw createError({ statusCode: 404, message: 'User not found' }); + + const [after] = await tx + .update(schema.users) + .set({ + uploaded: sql`greatest(0, ${schema.users.uploaded} + ${uploadedDelta})`, + downloaded: sql`greatest(0, ${schema.users.downloaded} + ${downloadedDelta})`, + }) + .where(eq(schema.users.id, userId)) + .returning({ uploaded: schema.users.uploaded, downloaded: schema.users.downloaded }); + + const applied = { + uploadedDelta: after!.uploaded - before.uploaded, + downloadedDelta: after!.downloaded - before.downloaded, + }; + if (applied.uploadedDelta === 0 && applied.downloadedDelta === 0) { + throw createError({ statusCode: 400, message: 'Nothing to remove: the totals are already 0' }); + } + + await tx.insert(schema.statAdjustments).values({ + id: randomUUID(), + userId, + adminId: session.user.id, + ...applied, + reason, + }); + + return { ...after!, ...applied }; + }); + + // The tracker caches user rows (ratio checks) for a few seconds + invalidateTrackerUsers(); + + return result; +}); diff --git a/server/db/migrations/0019_stat_adjustments.sql b/server/db/migrations/0019_stat_adjustments.sql new file mode 100644 index 00000000..14cc922d --- /dev/null +++ b/server/db/migrations/0019_stat_adjustments.sql @@ -0,0 +1,11 @@ +-- Admin corrections of uploaded/downloaded totals, with who and why +CREATE TABLE IF NOT EXISTS "stat_adjustments" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "admin_id" text REFERENCES "users"("id") ON DELETE SET NULL, + "uploaded_delta" bigint NOT NULL, + "downloaded_delta" bigint NOT NULL, + "reason" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +CREATE INDEX IF NOT EXISTS "stat_adjustments_user_idx" ON "stat_adjustments" ("user_id", "created_at"); diff --git a/server/db/schema.ts b/server/db/schema.ts index 312f636d..564a077f 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -570,6 +570,26 @@ export const bonusTransactions = pgTable( (table) => [index('bonus_tx_user_idx').on(table.userId, table.createdAt)] ); +/** + * Admin corrections of a member's uploaded/downloaded totals, with who and why. + * Deltas are the amounts actually applied (totals never go below 0). + */ +export const statAdjustments = pgTable( + 'stat_adjustments', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + adminId: text('admin_id').references(() => users.id, { onDelete: 'set null' }), + uploadedDelta: bigint('uploaded_delta', { mode: 'number' }).notNull(), + downloadedDelta: bigint('downloaded_delta', { mode: 'number' }).notNull(), + reason: text('reason').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [index('stat_adjustments_user_idx').on(table.userId, table.createdAt)] +); + export const shopItems = pgTable('shop_items', { id: text('id').primaryKey(), name: text('name').notNull(), diff --git a/shared/utils/statAdjust.ts b/shared/utils/statAdjust.ts new file mode 100644 index 00000000..ed94d0c1 --- /dev/null +++ b/shared/utils/statAdjust.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +/** + * Admin corrections of a member's uploaded/downloaded totals (ratio fixes, + * compensation after a tracker outage, penalties). + */ +export const STAT_UNITS = { + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, +} as const; +export type StatUnit = keyof typeof STAT_UNITS; + +// 1 PiB either way: a typo, not a correction +export const MAX_STAT_DELTA = 1024 ** 5; + +export const statAdjustmentSchema = z + .object({ + uploadedDelta: z.number().int().min(-MAX_STAT_DELTA).max(MAX_STAT_DELTA), + downloadedDelta: z.number().int().min(-MAX_STAT_DELTA).max(MAX_STAT_DELTA), + reason: z.string().trim().min(1).max(200), + }) + .refine((v) => v.uploadedDelta !== 0 || v.downloadedDelta !== 0, { message: 'Nothing to change' }); +export type StatAdjustmentInput = z.infer; + +/** Amount typed in the form, signed, to whole bytes */ +export function toBytes(amount: number, unit: StatUnit, remove = false): number { + if (!Number.isFinite(amount) || amount <= 0) return 0; + const bytes = Math.round(amount * STAT_UNITS[unit]); + return remove ? -bytes : bytes; +} + +/** New total and the delta actually applied: totals never go below 0 */ +export function applyStatDelta(current: number, delta: number): { value: number; applied: number } { + const value = Math.max(0, current + delta); + return { value, applied: value - current }; +} + +/** Same rule as the dashboard: nothing downloaded is ∞ once something was uploaded */ +export function ratioOf(uploaded: number, downloaded: number): number { + return downloaded === 0 ? (uploaded > 0 ? Infinity : 0) : uploaded / downloaded; +} diff --git a/tests/stat-adjust.test.ts b/tests/stat-adjust.test.ts new file mode 100644 index 00000000..7adf92ad --- /dev/null +++ b/tests/stat-adjust.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { MAX_STAT_DELTA, applyStatDelta, ratioOf, statAdjustmentSchema, toBytes } from '../shared/utils/statAdjust'; + +describe('toBytes', () => { + it('converts binary units and signs removals', () => { + expect(toBytes(1, 'GB')).toBe(1024 ** 3); + expect(toBytes(1.5, 'TB', true)).toBe(-1.5 * 1024 ** 4); + expect(toBytes(0.5, 'MB')).toBe(524288); + }); + + it('ignores empty, negative and invalid amounts', () => { + expect(toBytes(0, 'GB')).toBe(0); + expect(toBytes(-3, 'GB')).toBe(0); + expect(toBytes(Number.NaN, 'GB')).toBe(0); + }); +}); + +describe('applyStatDelta', () => { + it('adds and removes', () => { + expect(applyStatDelta(100, 50)).toEqual({ value: 150, applied: 50 }); + expect(applyStatDelta(100, -40)).toEqual({ value: 60, applied: -40 }); + }); + + it('never goes below zero and reports what was really removed', () => { + expect(applyStatDelta(100, -500)).toEqual({ value: 0, applied: -100 }); + expect(applyStatDelta(0, -1)).toEqual({ value: 0, applied: 0 }); + }); +}); + +describe('ratioOf', () => { + it('matches the dashboard rule', () => { + expect(ratioOf(0, 0)).toBe(0); + expect(ratioOf(10, 0)).toBe(Infinity); + expect(ratioOf(30, 20)).toBe(1.5); + }); +}); + +describe('statAdjustmentSchema', () => { + const base = { uploadedDelta: 0, downloadedDelta: 1024, reason: 'Tracker outage' }; + + it('accepts a correction with a reason', () => { + expect(statAdjustmentSchema.safeParse(base).success).toBe(true); + }); + + it('rejects empty changes, missing reasons and absurd amounts', () => { + expect(statAdjustmentSchema.safeParse({ ...base, downloadedDelta: 0 }).success).toBe(false); + expect(statAdjustmentSchema.safeParse({ ...base, reason: ' ' }).success).toBe(false); + expect(statAdjustmentSchema.safeParse({ ...base, uploadedDelta: MAX_STAT_DELTA + 1 }).success).toBe(false); + expect(statAdjustmentSchema.safeParse({ ...base, uploadedDelta: 1.5 }).success).toBe(false); + }); +});