Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions biome.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@
"packages/**",
"modules/*/*/pages/**",
"modules/*/*/components/**",
"modules/*/tests-js/**",
"!host/client_app/modules.generated.ts",
"!host/client_app/modules.manifest.json",
"!host/client_app/modules.generated.css",
Expand Down
4 changes: 2 additions & 2 deletions docs/modules/background_tasks.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,8 @@ DB-backed; defaults are in `BackgroundTasksSettings`. Several are marked `requir
| `stuck_after_seconds` | `300` | heartbeat-staleness threshold |
| `stuck_sweep_interval_seconds` | `60` | beat cadence for the stuck sweep |
| `purge_interval_seconds` | `86_400` | beat cadence for old-row purge |
| `retention_days` | `14` | how long to keep terminal rows |
| `max_retries` | `3` | informational; tasks define their own policies |
| `retention_days` | `14` | how long to keep terminal rows; range 1-3650 |
| `max_retries` | `3` | informational; tasks define their own policies; range 0-100 |

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

Expand Down
14 changes: 12 additions & 2 deletions modules/background_tasks/background_tasks/settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,8 +63,18 @@ class BackgroundTasksSettings(BaseSettings):
stuck_sweep_interval_seconds: int = DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS
purge_interval_seconds: int = DEFAULT_PURGE_INTERVAL_SECONDS

retention_days: int = DEFAULT_RETENTION_DAYS
max_retries: int = DEFAULT_MAX_RETRIES
retention_days: int = Field(
default=DEFAULT_RETENTION_DAYS,
ge=1,
le=3650,
description="Days to keep terminal task execution records (1-3650).",
)
max_retries: int = Field(
default=DEFAULT_MAX_RETRIES,
ge=0,
le=100,
description="Configured retry ceiling; individual tasks define their own policies (0-100).",
)

@model_validator(mode="after")
def _forbid_localhost_broker_in_production(self) -> BackgroundTasksSettings:
Expand Down
27 changes: 27 additions & 0 deletions modules/background_tasks/tests/test_background_tasks_settings.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

import pytest
from background_tasks.settings import BackgroundTasksSettings
from pydantic import ValidationError


@pytest.mark.parametrize("retention_days", [1, 3650])
def test_retention_days_accepts_supported_boundaries(retention_days: int) -> None:
assert BackgroundTasksSettings(retention_days=retention_days).retention_days == retention_days


@pytest.mark.parametrize("retention_days", [0, -5, 3651, 999_999_999])
def test_retention_days_rejects_values_outside_supported_range(retention_days: int) -> None:
with pytest.raises(ValidationError):
BackgroundTasksSettings(retention_days=retention_days)


@pytest.mark.parametrize("max_retries", [0, 100])
def test_max_retries_accepts_supported_boundaries(max_retries: int) -> None:
assert BackgroundTasksSettings(max_retries=max_retries).max_retries == max_retries


@pytest.mark.parametrize("max_retries", [-1, 101, 999_999_999])
def test_max_retries_rejects_values_outside_supported_range(max_retries: int) -> None:
with pytest.raises(ValidationError):
BackgroundTasksSettings(max_retries=max_retries)
4 changes: 3 additions & 1 deletion modules/settings/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,7 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"sonner": "^2.0.7"
}
}
1 change: 1 addition & 0 deletions modules/settings/settings/locales/en.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,7 @@
"modules_form": {
"save": "Save",
"saving": "Saving…",
"saved_toast": "Settings saved",
"default_group": "General",
"requires_restart": "Requires restart",
"reset_to_default": "Reset to default",
Expand Down
27 changes: 22 additions & 5 deletions modules/settings/settings/pages/components/ModuleForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,23 +36,27 @@ export function ModuleForm({ module: m, testable = false }: Props) {
}, [m.fields]);

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

// Reset the edit buffer whenever the underlying module changes (package
// switch, or server-reloaded props after a save/reset).
useEffect(() => {
setValues(initial);
setBaseline(initial);
setErrors({});
setSaved(false);
}, [initial]);

const modifiedFields = useMemo(() => {
const s = new Set<string>();
for (const name of Object.keys(values)) {
if (notEqual(values[name], initial[name])) s.add(name);
if (notEqual(values[name], baseline[name])) s.add(name);
}
return s;
}, [values, initial]);
}, [values, baseline]);

const defaultByName = useMemo(() => {
const o: Record<string, unknown> = {};
Expand All@@ -74,6 +78,7 @@ export function ModuleForm({ module: m, testable = false }: Props) {

async function onSave() {
setBusy(true);
setSaved(false);
setErrors({});
const changed: Record<string, unknown> = {};
for (const name of modifiedFields) changed[name] = values[name];
Expand All@@ -90,12 +95,16 @@ export function ModuleForm({ module: m, testable = false }: Props) {
}
setErrors(fieldErrs);
} else if (resp.ok) {
router.reload({ only: ['modules'] });
// Keeping the form mounted lets the confirmation remain visible instead
// of being discarded by an immediate Inertia reload.
setBaseline({ ...values });
setSaved(true);
}
setBusy(false);
}

async function onReset(name: string) {
setSaved(false);
await fetch(`/api/settings/modules/${m.package}/${name}`, { method: 'DELETE' });
router.reload({ only: ['modules'] });
}
Expand All@@ -107,7 +116,12 @@ export function ModuleForm({ module: m, testable = false }: Props) {
<h2 className="text-xl font-semibold">{m.module_name}</h2>
<p className="text-xs font-mono text-muted-foreground">{m.package}</p>
</div>
<div className="flex items-start gap-2">
<div className="flex items-center gap-2">
{saved && (
<p role="status" className="text-sm font-medium text-emerald-700">
{t(keys.settings.modules_form.saved_toast)}
</p>
)}
{testable && <TestConnectionButton pkg={m.package} />}
<button
type="button"
Expand DownExpand Up@@ -146,7 +160,10 @@ export function ModuleForm({ module: m, testable = false }: Props) {
id={`field-${m.package}-${f.name}`}
field={f}
value={values[f.name]}
onChange={(name, v) => setValues((prev) => ({ ...prev, [name]: v }))}
onChange={(name, v) => {
setSaved(false);
setValues((prev) => ({ ...prev, [name]: v }));
}}
/>
{isModified && (
<button
Expand Down
63 changes: 63 additions & 0 deletions modules/settings/tests-js/ModuleForm.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import '@testing-library/jest-dom/vitest';
import { configureI18n } from '@simple-module-py/i18n';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';

configureI18n({
locale: 'en',
messages: {
'settings.modules_form.saved_toast': 'Settings saved',
},
});

const mocks = vi.hoisted(() => ({ reload: vi.fn() }));

vi.mock('@inertiajs/react', () => ({
router: { reload: mocks.reload },
}));

import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm';

const moduleView: ModuleView = {
module_name: 'BackgroundTasks',
package: 'background_tasks',
env_prefix: 'SM_BG_TASKS_',
class_name: 'BackgroundTasksSettings',
fields: [
{
name: 'retention_days',
type: 'int',
value: 14,
default: 14,
description: '',
is_secret: false,
requires_restart: false,
group: null,
env_var: 'SM_BG_TASKS_RETENTION_DAYS',
},
],
};

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

describe('ModuleForm', () => {
test('shows success feedback after settings are saved', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
render(<ModuleForm module={moduleView} />);

fireEvent.change(screen.getByLabelText('retention_days'), { target: { value: '30' } });
fireEvent.click(screen.getByRole('button', { name: /modules_form\.save$/ }));

await waitFor(() => expect(screen.getByRole('status')).toBeVisible());
expect(fetchMock).toHaveBeenCalledWith(
'/api/settings/modules/background_tasks',
expect.objectContaining({ body: JSON.stringify({ retention_days: 30 }) }),
);
expect(mocks.reload).not.toHaveBeenCalled();
expect(screen.getByRole('status')).toHaveTextContent('Settings saved');
});
});
22 changes: 22 additions & 0 deletions modules/settings/tests/test_module_api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,28 @@ async def test_put_validation_error_surfaces_422(authenticated_client, app):
assert "i18n_default_locale" in resp.text


@pytest.mark.asyncio
async def test_put_rejects_and_does_not_persist_unbounded_background_task_values(
authenticated_client, app, db_session
):
from settings.service import SettingService
from settings.store import SettingsStore

original = app.state.background_tasks.settings
resp = await authenticated_client.put(
"/api/settings/modules/background_tasks",
json={"retention_days": -5, "max_retries": 999_999_999},
)

assert resp.status_code == 422
assert {error["loc"][-1] for error in resp.json()["detail"]} == {
"retention_days",
"max_retries",
}
assert app.state.background_tasks.settings is original
assert await SettingsStore(SettingService(db_session)).get_overrides("background_tasks") == {}


@pytest.mark.asyncio
async def test_delete_field_resets_to_default(authenticated_client, app):
await authenticated_client.put("/api/settings/modules/host", json={"multi_tenant": True})
Expand Down
2 changes: 1 addition & 1 deletion modules/settings/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,5 +6,5 @@
"@simple-module-py/ui/*": ["../../packages/ui/src/*"]
}
},
"include": ["settings/**/*.ts", "settings/**/*.tsx"]
"include": ["settings/**/*.ts", "settings/**/*.tsx", "tests-js/**/*.ts", "tests-js/**/*.tsx"]
}
50 changes: 50 additions & 0 deletions modules/users/tests-js/invite-emails.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import '@testing-library/jest-dom/vitest';
import { configureI18n } from '@simple-module-py/i18n';
import { render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';

configureI18n({
locale: 'en',
messages: {
'users.invite_fields.invalid_email_one': '{email} is not a valid email address.',
'users.invite_fields.invalid_email_other': '{count} addresses are not valid email addresses.',
},
});

import { InviteFields } from '../users/pages/Users/components/InviteFields';
import { isPlausibleEmail, parseInviteEmails } from '../users/pages/Users/invite-emails';

describe('invite email validation', () => {
test('parses pasted addresses using every supported separator', () => {
expect(parseInviteEmails('one@example.com; two@example.com\nthree@example.com')).toEqual([
'one@example.com',
'two@example.com',
'three@example.com',
]);
});

test.each([
['teammate@example.com', true],
['not-an-email', false],
['missing-domain@', false],
['missing-tld@example', false],
])('classifies %s', (email, expected) => {
expect(isPlausibleEmail(email)).toBe(expected);
});

test('warns about an invalid address before submit', () => {
render(
<InviteFields
emails="not-an-email"
onEmailsChange={vi.fn()}
count={0}
invalidEmails={['not-an-email']}
mailerDelivers
/>,
);

expect(screen.getByRole('alert')).toHaveTextContent(
'not-an-email is not a valid email address.',
);
});
});
2 changes: 1 addition & 1 deletion modules/users/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,5 +6,5 @@
"@simple-module-py/ui/*": ["../../packages/ui/src/*"]
}
},
"include": ["users/**/*.ts", "users/**/*.tsx"]
"include": ["users/**/*.ts", "users/**/*.tsx", "tests-js/**/*.ts", "tests-js/**/*.tsx"]
}
2 changes: 2 additions & 0 deletions modules/users/users/locales/en.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -229,6 +229,8 @@
"label": "Email addresses",
"hint": "One per line, or separated by commas.",
"recognised": "{count} recognised.",
"invalid_email_one": "{email} is not a valid email address.",
"invalid_email_other": "{count} addresses are not valid email addresses.",
"no_mailer": "This deployment logs invite mail instead of sending it. You will get a copyable link for each address to pass on yourself."
},
"create_fields": {
Expand Down
16 changes: 9 additions & 7 deletions modules/users/users/pages/Users/AddPeople.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import { CreateUserFields } from './components/CreateUserFields';
import { InviteFields } from './components/InviteFields';
import { type InviteResult, InviteResults } from './components/InviteResults';
import { type Role, RolePicker } from './components/RolePicker';
import { isPlausibleEmail, parseInviteEmails } from './invite-emails';

type Mode = 'invite' | 'create';

Expand DownExpand Up@@ -59,11 +60,9 @@ function AddPeople() {
prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName],
);

/** Split a pasted block on commas, semicolons, and any whitespace. */
const parsedEmails = emails
.split(/[\s,;]+/)
.map((value) => value.trim())
.filter(Boolean);
const parsedEmails = parseInviteEmails(emails);
const invalidEmails = parsedEmails.filter((value) => !isPlausibleEmail(value));
const validEmailCount = parsedEmails.length - invalidEmails.length;

async function submitInvite() {
const resp = await fetch('/api/users/admin/invite/bulk', {
Expand DownExpand Up@@ -134,7 +133,9 @@ function AddPeople() {
}

const canSubmit =
mode === 'invite' ? parsedEmails.length > 0 : email.trim() !== '' && password !== '';
mode === 'invite'
? validEmailCount > 0 && invalidEmails.length === 0
: email.trim() !== '' && password !== '';

const submitLabel = loading
? t(keys.users.add_people.submitting)
Expand DownExpand Up@@ -187,7 +188,8 @@ function AddPeople() {
<InviteFields
emails={emails}
onEmailsChange={setEmails}
count={parsedEmails.length}
count={validEmailCount}
invalidEmails={invalidEmails}
mailerDelivers={mailerDelivers}
/>
) : (
Expand Down
Loading
Loading