Skip to content

Commit 52ebdf5

Browse files
authored
fix: background_tasks: settings form accepts unbounded numeric values and gives no save feedback (#279)
* fix: background_tasks: settings form accepts unbounded numeric values and gives no save feedback (#270) * fix: address review feedback for #270 * fix: address review feedback for #270 * fix: address review feedback for #270
1 parent b88a806 commit 52ebdf5

20 files changed

Lines changed: 263 additions & 21 deletions

File tree

‎biome.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"packages/**",
77
"modules/*/*/pages/**",
88
"modules/*/*/components/**",
9+
"modules/*/tests-js/**",
910
"!host/client_app/modules.generated.ts",
1011
"!host/client_app/modules.manifest.json",
1112
"!host/client_app/modules.generated.css",

‎docs/modules/background_tasks.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ DB-backed; defaults are in `BackgroundTasksSettings`. Several are marked `requir
148148
| `stuck_after_seconds` | `300` | heartbeat-staleness threshold |
149149
| `stuck_sweep_interval_seconds` | `60` | beat cadence for the stuck sweep |
150150
| `purge_interval_seconds` | `86_400` | beat cadence for old-row purge |
151-
| `retention_days` | `14` | how long to keep terminal rows |
152-
| `max_retries` | `3` | informational; tasks define their own policies |
151+
| `retention_days` | `14` | how long to keep terminal rows; range 1-3650 |
152+
| `max_retries` | `3` | informational; tasks define their own policies; range 0-100 |
153153

154154
Bootstrap env-var equivalents (`SM_BG_TASKS_*`) only seed pydantic defaults at first boot — once a value lives in the DB, it's authoritative.
155155

‎modules/background_tasks/background_tasks/settings.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,18 @@ class BackgroundTasksSettings(BaseSettings):
8080
stuck_sweep_interval_seconds: int = DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS
8181
purge_interval_seconds: int = DEFAULT_PURGE_INTERVAL_SECONDS
8282

83-
retention_days: int = DEFAULT_RETENTION_DAYS
84-
max_retries: int = DEFAULT_MAX_RETRIES
83+
retention_days: int = Field(
84+
default=DEFAULT_RETENTION_DAYS,
85+
ge=1,
86+
le=3650,
87+
description="Days to keep terminal task execution records (1-3650).",
88+
)
89+
max_retries: int = Field(
90+
default=DEFAULT_MAX_RETRIES,
91+
ge=0,
92+
le=100,
93+
description="Configured retry ceiling; individual tasks define their own policies (0-100).",
94+
)
8595

8696
@model_validator(mode="after")
8797
def _forbid_localhost_broker_in_production(self) -> BackgroundTasksSettings:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from background_tasks.settings import BackgroundTasksSettings
5+
from pydantic import ValidationError
6+
7+
8+
@pytest.mark.parametrize("retention_days", [1, 3650])
9+
def test_retention_days_accepts_supported_boundaries(retention_days: int) -> None:
10+
assert BackgroundTasksSettings(retention_days=retention_days).retention_days == retention_days
11+
12+
13+
@pytest.mark.parametrize("retention_days", [0, -5, 3651, 999_999_999])
14+
def test_retention_days_rejects_values_outside_supported_range(retention_days: int) -> None:
15+
with pytest.raises(ValidationError):
16+
BackgroundTasksSettings(retention_days=retention_days)
17+
18+
19+
@pytest.mark.parametrize("max_retries", [0, 100])
20+
def test_max_retries_accepts_supported_boundaries(max_retries: int) -> None:
21+
assert BackgroundTasksSettings(max_retries=max_retries).max_retries == max_retries
22+
23+
24+
@pytest.mark.parametrize("max_retries", [-1, 101, 999_999_999])
25+
def test_max_retries_rejects_values_outside_supported_range(max_retries: int) -> None:
26+
with pytest.raises(ValidationError):
27+
BackgroundTasksSettings(max_retries=max_retries)

‎modules/settings/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@
1212
"devDependencies": {
1313
"@simple-module-py/tsconfig": "*"
1414
},
15-
"dependencies": {}
15+
"dependencies": {
16+
"sonner": "^2.0.7"
17+
}
1618
}

‎modules/settings/settings/locales/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"modules_form": {
9292
"save": "Save",
9393
"saving": "Saving…",
94+
"saved_toast": "Settings saved",
9495
"default_group": "General",
9596
"requires_restart": "Requires restart",
9697
"reset_to_default": "Reset to default",

‎modules/settings/settings/pages/components/ModuleForm.tsx‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,27 @@ export function ModuleForm({ module: m, testable = false }: Props) {
3838
}, [m.fields]);
3939

4040
const [values, setValues] = useState<Record<string, unknown>>(initial);
41+
const [baseline, setBaseline] = useState<Record<string, unknown>>(initial);
4142
const [errors, setErrors] = useState<Record<string, string>>({});
4243
const [busy, setBusy] = useState(false);
44+
const [saved, setSaved] = useState(false);
4345

4446
// Reset the edit buffer whenever the underlying module changes (package
4547
// switch, or server-reloaded props after a save/reset).
4648
useEffect(() => {
4749
setValues(initial);
50+
setBaseline(initial);
4851
setErrors({});
52+
setSaved(false);
4953
}, [initial]);
5054

5155
const modifiedFields = useMemo(() => {
5256
const s = new Set<string>();
5357
for (const name of Object.keys(values)) {
54-
if (notEqual(values[name], initial[name])) s.add(name);
58+
if (notEqual(values[name], baseline[name])) s.add(name);
5559
}
5660
return s;
57-
}, [values, initial]);
61+
}, [values, baseline]);
5862

5963
const defaultByName = useMemo(() => {
6064
const o: Record<string, unknown> = {};
@@ -76,6 +80,7 @@ export function ModuleForm({ module: m, testable = false }: Props) {
7680

7781
async function onSave() {
7882
setBusy(true);
83+
setSaved(false);
7984
setErrors({});
8085
const changed: Record<string, unknown> = {};
8186
for (const name of modifiedFields) changed[name] = values[name];
@@ -92,12 +97,16 @@ export function ModuleForm({ module: m, testable = false }: Props) {
9297
}
9398
setErrors(fieldErrs);
9499
} else if (resp.ok) {
95-
router.reload({ only: ['modules'] });
100+
// Keeping the form mounted lets the confirmation remain visible instead
101+
// of being discarded by an immediate Inertia reload.
102+
setBaseline({ ...values });
103+
setSaved(true);
96104
}
97105
setBusy(false);
98106
}
99107

100108
async function onReset(name: string) {
109+
setSaved(false);
101110
await fetch(`/api/settings/modules/${m.package}/${name}`, { method: 'DELETE' });
102111
router.reload({ only: ['modules'] });
103112
}
@@ -109,7 +118,12 @@ export function ModuleForm({ module: m, testable = false }: Props) {
109118
<h2 className="text-xl font-semibold">{m.module_name}</h2>
110119
<p className="text-xs font-mono text-muted-foreground">{m.package}</p>
111120
</div>
112-
<div className="flex items-start gap-2">
121+
<div className="flex items-center gap-2">
122+
{saved && (
123+
<p role="status" className="text-sm font-medium text-emerald-700">
124+
{t(keys.settings.modules_form.saved_toast)}
125+
</p>
126+
)}
113127
{testable && <TestConnectionButton pkg={m.package} />}
114128
<button
115129
type="button"
@@ -148,7 +162,10 @@ export function ModuleForm({ module: m, testable = false }: Props) {
148162
id={`field-${m.package}-${f.name}`}
149163
field={f}
150164
value={values[f.name]}
151-
onChange={(name, v) => setValues((prev) => ({ ...prev, [name]: v }))}
165+
onChange={(name, v) => {
166+
setSaved(false);
167+
setValues((prev) => ({ ...prev, [name]: v }));
168+
}}
152169
/>
153170
{isModified && (
154171
<button
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import '@testing-library/jest-dom/vitest';
2+
import { configureI18n } from '@simple-module-py/i18n';
3+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4+
import { afterEach, describe, expect, test, vi } from 'vitest';
5+
6+
configureI18n({
7+
locale: 'en',
8+
messages: {
9+
'settings.modules_form.saved_toast': 'Settings saved',
10+
},
11+
});
12+
13+
const mocks = vi.hoisted(() => ({ reload: vi.fn() }));
14+
15+
vi.mock('@inertiajs/react', () => ({
16+
router: { reload: mocks.reload },
17+
}));
18+
19+
import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm';
20+
21+
const moduleView: ModuleView = {
22+
module_name: 'BackgroundTasks',
23+
package: 'background_tasks',
24+
env_prefix: 'SM_BG_TASKS_',
25+
class_name: 'BackgroundTasksSettings',
26+
fields: [
27+
{
28+
name: 'retention_days',
29+
type: 'int',
30+
value: 14,
31+
default: 14,
32+
description: '',
33+
is_secret: false,
34+
requires_restart: false,
35+
group: null,
36+
env_var: 'SM_BG_TASKS_RETENTION_DAYS',
37+
},
38+
],
39+
};
40+
41+
afterEach(() => {
42+
vi.unstubAllGlobals();
43+
vi.clearAllMocks();
44+
});
45+
46+
describe('ModuleForm', () => {
47+
test('shows success feedback after settings are saved', async () => {
48+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
49+
vi.stubGlobal('fetch', fetchMock);
50+
render(<ModuleForm module={moduleView} />);
51+
52+
fireEvent.change(screen.getByLabelText('retention_days'), { target: { value: '30' } });
53+
fireEvent.click(screen.getByRole('button', { name: /modules_form\.save$/ }));
54+
55+
await waitFor(() => expect(screen.getByRole('status')).toBeVisible());
56+
expect(fetchMock).toHaveBeenCalledWith(
57+
'/api/settings/modules/background_tasks',
58+
expect.objectContaining({ body: JSON.stringify({ retention_days: 30 }) }),
59+
);
60+
expect(mocks.reload).not.toHaveBeenCalled();
61+
expect(screen.getByRole('status')).toHaveTextContent('Settings saved');
62+
});
63+
});

‎modules/settings/tests/test_module_api.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ async def test_put_validation_error_surfaces_422(authenticated_client, app):
3232
assert "i18n_default_locale" in resp.text
3333

3434

35+
@pytest.mark.asyncio
36+
async def test_put_rejects_and_does_not_persist_unbounded_background_task_values(
37+
authenticated_client, app, db_session
38+
):
39+
from settings.service import SettingService
40+
from settings.store import SettingsStore
41+
42+
original = app.state.background_tasks.settings
43+
resp = await authenticated_client.put(
44+
"/api/settings/modules/background_tasks",
45+
json={"retention_days": -5, "max_retries": 999_999_999},
46+
)
47+
48+
assert resp.status_code == 422
49+
assert {error["loc"][-1] for error in resp.json()["detail"]} == {
50+
"retention_days",
51+
"max_retries",
52+
}
53+
assert app.state.background_tasks.settings is original
54+
assert await SettingsStore(SettingService(db_session)).get_overrides("background_tasks") == {}
55+
56+
3557
@pytest.mark.asyncio
3658
async def test_delete_field_resets_to_default(authenticated_client, app):
3759
await authenticated_client.put("/api/settings/modules/host", json={"multi_tenant": True})

‎modules/settings/tsconfig.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"@simple-module-py/ui/*": ["../../packages/ui/src/*"]
77
}
88
},
9-
"include": ["settings/**/*.ts", "settings/**/*.tsx"]
9+
"include": ["settings/**/*.ts", "settings/**/*.tsx", "tests-js/**/*.ts", "tests-js/**/*.tsx"]
1010
}

0 commit comments

Comments
 (0)