}
`)
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
Error!
return
{data}
}
`)
_, 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
}
`)
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
}
`)
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
}
`)
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
}
`)
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
}
`)
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
}
`)
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
}
`)
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")
}
}