check-safety/rpcerrors_test.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:04:02 +08:00

330 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"os"
"path/filepath"
"testing"
)
// makeRPCDir creates a webSrcDir with optional lib/query-client.ts for the
// global mutation handler check.
func makeRPCDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
return dir
}
func writeRPCComponent(t *testing.T, webSrcDir, subpath, content string) {
t.Helper()
fullPath := filepath.Join(webSrcDir, subpath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(fullPath), err)
}
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", fullPath, err)
}
}
func writeGlobalMutationHandler(t *testing.T, webSrcDir string) {
t.Helper()
libDir := filepath.Join(webSrcDir, "lib")
if err := os.MkdirAll(libDir, 0755); err != nil {
t.Fatalf("mkdir lib: %v", err)
}
if err := os.WriteFile(filepath.Join(libDir, "query-client.ts"), []byte(`
import { MutationCache, QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error) => {
console.error('Global mutation error', error)
},
}),
})
`), 0644); err != nil {
t.Fatalf("write query-client.ts: %v", err)
}
}
// ─────────────────────────────────────────────
// checkRPCErrors query-no-error warning
// ─────────────────────────────────────────────
func TestCheckRPCErrors_QueryWithoutError_Warning(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/my-component.tsx", `
import { useQuery } from '@block-ninja/api/queries'
export function MyComponent() {
const { data, isLoading } = useQuery(myQuery, {})
return <div>{data}</div>
}
`)
violations, warnings := checkRPCErrors(dir)
if len(warnings) == 0 {
t.Fatal("expected at least 1 warning for useQuery without error destructuring")
}
found := false
for _, w := range warnings {
if w.rule == "query-no-error" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'query-no-error' in warnings, got: %#v", warnings)
}
// Should not be a hard violation
if len(violations) != 0 {
t.Fatalf("expected 0 hard violations for missing query error, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_QueryWithError_NoWarning(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/my-component.tsx", `
import { useQuery } from '@block-ninja/api/queries'
export function MyComponent() {
const { data, isLoading, error } = useQuery(myQuery, {})
if (error) return <div>Error!</div>
return <div>{data}</div>
}
`)
_, warnings := checkRPCErrors(dir)
if len(warnings) != 0 {
t.Fatalf("expected 0 warnings when error is destructured, got %d: %#v", len(warnings), warnings)
}
}
// ─────────────────────────────────────────────
// checkRPCErrors mutation-no-error-handler violation
// ─────────────────────────────────────────────
func TestCheckRPCErrors_MutationWithNoErrorHandler_Violation(t *testing.T) {
dir := makeRPCDir(t)
// No global handler, no onError, no try/catch, no toast.error
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Editor() {
const saveMutation = useMutation(saveBlock)
function handleSave() {
saveMutation.mutate({ content: 'hello' })
}
return <button onClick={handleSave}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) == 0 {
t.Fatal("expected violation for mutation without error handler")
}
found := false
for _, v := range violations {
if v.rule == "mutation-no-error-handler" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'mutation-no-error-handler', got: %#v", violations)
}
}
func TestCheckRPCErrors_MutationWithOnError_Clean(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
import { toast } from 'sonner'
export function Editor() {
const saveMutation = useMutation(saveBlock, {
onError: (err) => toast.error('Save failed', { description: err.message }),
onSuccess: () => toast.success('Saved'),
})
return <button onClick={() => saveMutation.mutate({})}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations for mutation with onError, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_MutationWithTryCatch_Clean(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Editor() {
const saveMutation = useMutation(saveBlock)
async function handleSave() {
try {
await saveMutation.mutateAsync({})
} catch (err) {
console.error(err)
}
}
return <button onClick={handleSave}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations when try/catch is present, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_MutationWithToastError_Clean(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
import { toast } from 'sonner'
export function Editor() {
const saveMutation = useMutation(saveBlock)
function handleSave() {
saveMutation.mutate({}, {
onSuccess: () => toast.success('Done'),
})
// fallback: toast.error happens elsewhere
}
function handleError(err) {
toast.error('Failed: ' + err.message)
}
return <button onClick={handleSave}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations when toast.error is present in file, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_GlobalHandlerSuppressesViolation(t *testing.T) {
dir := makeRPCDir(t)
writeGlobalMutationHandler(t, dir)
// Mutation with no per-mutation error handling — suppressed by global handler
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Editor() {
const saveMutation = useMutation(saveBlock)
return <button onClick={() => saveMutation.mutate({})}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations when global mutation handler is present, got %d: %#v", len(violations), violations)
}
}
// ─────────────────────────────────────────────
// checkRPCErrors file path scoping
// ─────────────────────────────────────────────
func TestCheckRPCErrors_SkipsFilesOutsideComponentsAndRoutes(t *testing.T) {
dir := makeRPCDir(t)
// File is in lib/ — not components/ or routes/, should be skipped
writeRPCComponent(t, dir, "lib/utils.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function useHelperMutation() {
return useMutation(doSomething)
}
`)
violations, warnings := checkRPCErrors(dir)
if len(violations) != 0 || len(warnings) != 0 {
t.Fatalf("expected 0 violations and 0 warnings for file outside scoped dirs, got v=%d w=%d",
len(violations), len(warnings))
}
}
func TestCheckRPCErrors_ChecksRouteFiles(t *testing.T) {
dir := makeRPCDir(t)
// File in routes/ should be checked — mutation with no handler
writeRPCComponent(t, dir, "routes/dashboard.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Dashboard() {
const doThing = useMutation(action)
return <button onClick={() => doThing.mutate({})}>Go</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) == 0 {
t.Fatal("expected violation for mutation in routes/ without error handler")
}
}
// ─────────────────────────────────────────────
// checkRPCErrors comment lines skipped
// ─────────────────────────────────────────────
func TestCheckRPCErrors_CommentedMutationSkipped(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/thing.tsx", `
export function Thing() {
// const doThing = useMutation(action)
return <div />
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations for commented-out mutation, got %d: %#v", len(violations), violations)
}
}
// ─────────────────────────────────────────────
// hasGlobalMutationErrorHandler
// ─────────────────────────────────────────────
func TestHasGlobalMutationErrorHandler_Present(t *testing.T) {
dir := makeRPCDir(t)
writeGlobalMutationHandler(t, dir)
if !hasGlobalMutationErrorHandler(dir) {
t.Fatal("expected hasGlobalMutationErrorHandler to return true when handler is present")
}
}
func TestHasGlobalMutationErrorHandler_Missing(t *testing.T) {
dir := makeRPCDir(t)
if hasGlobalMutationErrorHandler(dir) {
t.Fatal("expected hasGlobalMutationErrorHandler to return false when file is absent")
}
}
func TestHasGlobalMutationErrorHandler_PartialContent(t *testing.T) {
dir := makeRPCDir(t)
libDir := filepath.Join(dir, "lib")
if err := os.MkdirAll(libDir, 0755); err != nil {
t.Fatalf("mkdir lib: %v", err)
}
// Has MutationCache but no onError
if err := os.WriteFile(filepath.Join(libDir, "query-client.ts"), []byte(`
import { MutationCache } from '@tanstack/react-query'
const cache = new MutationCache({})
`), 0644); err != nil {
t.Fatalf("write query-client.ts: %v", err)
}
if hasGlobalMutationErrorHandler(dir) {
t.Fatal("expected false when MutationCache present but onError absent")
}
}