test(stores): cover settings merge/clamp + notification sanitizer (29→42 tests)

Extract two pure helpers so they're testable without zustand/expo:
- settings-merge.ts: clampPageSize + forward-compatible mergeStoredSettings (the
  upgrade path where stored data predates a new notification category must yield
  the default, not undefined). stores/settings.ts now delegates.
- notify-format.ts: sanitizeBody (strip C0/DEL control chars, trim, cap at 200)
  used for server-supplied notification text. stores/events.ts now imports it.
Behavior unchanged; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
engineer
2026-06-03 14:01:29 -07:00
parent dd55ca471d
commit 48bc314dfc
6 changed files with 137 additions and 8 deletions

View File

@@ -0,0 +1,41 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { sanitizeBody, MAX_NOTIF_BODY } from "./notify-format.ts"
// Notification bodies come from the server (and ultimately the AI). They must be
// neutralized before display: control chars stripped, whitespace trimmed, length capped.
test("returns the fallback for undefined or empty input", () => {
assert.equal(sanitizeBody(undefined, "fb"), "fb")
assert.equal(sanitizeBody("", "fb"), "fb")
})
test("returns the fallback when input is only whitespace/control chars", () => {
assert.equal(sanitizeBody(" ", "fb"), "fb")
assert.equal(sanitizeBody("\x00\x07\x1f", "fb"), "fb")
})
test("passes ordinary text through, trimmed", () => {
assert.equal(sanitizeBody(" hello world ", "fb"), "hello world")
})
test("replaces C0 control chars and DEL with spaces", () => {
// newline, tab, bell, DEL between the words become spaces
assert.equal(sanitizeBody("a\nb\tc\x07d\x7fe", "fb"), "a b c d e")
})
test("does not strip non-ASCII/emoji content", () => {
assert.equal(sanitizeBody("café 🚀 プロジェクト", "fb"), "café 🚀 プロジェクト")
})
test("caps length at MAX_NOTIF_BODY", () => {
const long = "x".repeat(500)
const out = sanitizeBody(long, "fb")
assert.equal(out.length, MAX_NOTIF_BODY)
assert.equal(out, "x".repeat(MAX_NOTIF_BODY))
})
test("trims before slicing so leading whitespace doesn't eat the budget", () => {
const out = sanitizeBody(" " + "y".repeat(250), "fb")
assert.equal(out, "y".repeat(MAX_NOTIF_BODY))
})

14
src/lib/notify-format.ts Normal file
View File

@@ -0,0 +1,14 @@
// Pure formatting for notification bodies, extracted from stores/events.ts so the
// sanitization rules are unit-testable without the event store's RN dependencies.
export const MAX_NOTIF_BODY = 200
/**
* Make a server-supplied string safe to show in a notification:
* strip C0 control characters and DEL (which can corrupt the notification shade
* or hide content), collapse surrounding whitespace, and cap the length. Falls
* back to `fallback` when the input is empty/whitespace-only or undefined.
*/
export function sanitizeBody(s: string | undefined, fallback: string): string {
return (s ? s.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, MAX_NOTIF_BODY) : "") || fallback
}

View File

@@ -0,0 +1,52 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { clampPageSize, mergeStoredSettings } from "./settings-merge.ts"
const DEFAULTS = {
pageSize: 25,
notifications: { idle: true, error: true, permission: false },
}
test("clampPageSize keeps in-range values unchanged", () => {
assert.equal(clampPageSize(25), 25)
assert.equal(clampPageSize(10), 10)
assert.equal(clampPageSize(200), 200)
})
test("clampPageSize floors below 10 and caps above 200", () => {
assert.equal(clampPageSize(0), 10)
assert.equal(clampPageSize(-5), 10)
assert.equal(clampPageSize(9), 10)
assert.equal(clampPageSize(201), 200)
assert.equal(clampPageSize(99999), 200)
})
test("empty stored settings yield the defaults", () => {
assert.deepEqual(mergeStoredSettings(DEFAULTS, {}), DEFAULTS)
})
test("stored values override defaults", () => {
const merged = mergeStoredSettings(DEFAULTS, { pageSize: 50 })
assert.equal(merged.pageSize, 50)
assert.deepEqual(merged.notifications, DEFAULTS.notifications)
})
test("upgrade path: a category missing from storage gets its default", () => {
// Stored data predates the "permission" category — it must come back as the default (false),
// not undefined, while the user's stored choices are preserved.
const merged = mergeStoredSettings(DEFAULTS, { notifications: { idle: false, error: true } })
assert.equal(merged.notifications.idle, false) // user's stored choice kept
assert.equal(merged.notifications.error, true)
assert.equal(merged.notifications.permission, false) // new category -> default
assert.equal("permission" in merged.notifications, true)
})
test("does not mutate the inputs", () => {
const defaults = { pageSize: 25, notifications: { a: true } }
const parsed = { notifications: { a: false } }
const merged = mergeStoredSettings(defaults, parsed)
assert.equal(defaults.notifications.a, true) // untouched
assert.equal(parsed.notifications.a, false) // untouched
assert.equal(merged.notifications.a, false)
assert.notEqual(merged.notifications, defaults.notifications)
})

25
src/lib/settings-merge.ts Normal file
View File

@@ -0,0 +1,25 @@
// Pure settings helpers extracted from stores/settings.ts so the clamp + the
// forward-compatible merge (the upgrade path: stored data from an older app
// version that predates a newer notification category) are unit-testable without
// pulling in zustand / expo-secure-store.
export function clampPageSize(size: number): number {
return Math.max(10, Math.min(200, size))
}
/**
* Merge stored settings over defaults. Stored values win, but any top-level field
* or notification category missing from storage falls back to its default — so a
* user upgrading to a build with a new setting gets that setting's default rather
* than `undefined`. Defaults are passed in to keep this module dependency-free.
*/
export function mergeStoredSettings<T extends { notifications: Record<string, boolean> }>(
defaults: T,
parsed: Partial<T>,
): T {
return {
...defaults,
...parsed,
notifications: { ...defaults.notifications, ...parsed.notifications },
}
}

View File

@@ -2,10 +2,7 @@ import { create } from "zustand"
import { useConnections } from "./connections" import { useConnections } from "./connections"
import { useSessions } from "./sessions" import { useSessions } from "./sessions"
import { send as notify } from "../lib/notifications" import { send as notify } from "../lib/notifications"
import { sanitizeBody } from "../lib/notify-format"
const MAX_NOTIF_BODY = 200
const sanitizeBody = (s: string | undefined, fallback: string): string =>
(s ? s.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, MAX_NOTIF_BODY) : "") || fallback
import { addBreadcrumb } from "../lib/sentry" import { addBreadcrumb } from "../lib/sentry"
import type { Client, Part, Session, Message } from "../lib/sdk" import type { Client, Part, Session, Message } from "../lib/sdk"

View File

@@ -1,6 +1,7 @@
import { create } from "zustand" import { create } from "zustand"
import * as SecureStore from "expo-secure-store" import * as SecureStore from "expo-secure-store"
import { type Category, defaultPreferences } from "../lib/notifications" import { type Category, defaultPreferences } from "../lib/notifications"
import { clampPageSize, mergeStoredSettings } from "../lib/settings-merge"
const SETTINGS_KEY = "opencode_settings" const SETTINGS_KEY = "opencode_settings"
@@ -37,16 +38,15 @@ export const useSettings = create<SettingsState>((set, get) => ({
const raw = await SecureStore.getItemAsync(SETTINGS_KEY) const raw = await SecureStore.getItemAsync(SETTINGS_KEY)
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as Partial<Settings> const parsed = JSON.parse(raw) as Partial<Settings>
// Merge stored notifications with defaults so new categories get their default // Merge stored settings with defaults so new fields/categories get their default
const notifications = { ...DEFAULTS.notifications, ...parsed.notifications } set({ ...mergeStoredSettings(DEFAULTS, parsed), loaded: true })
set({ ...DEFAULTS, ...parsed, notifications, loaded: true })
return return
} }
set({ loaded: true }) set({ loaded: true })
}, },
setPageSize: async (size) => { setPageSize: async (size) => {
const clamped = Math.max(10, Math.min(200, size)) const clamped = clampPageSize(size)
set({ pageSize: clamped }) set({ pageSize: clamped })
await persist({ ...snapshot(get), pageSize: clamped }) await persist({ ...snapshot(get), pageSize: clamped })
}, },