Refactor ServerTab and ConfFilesTab to use reducers

This commit is contained in:
2026-04-21 19:52:05 +02:00
parent 260ce7e875
commit 1ba82d56e7
5 changed files with 482 additions and 130 deletions

View File

@@ -452,7 +452,11 @@ const source = timeRange === "24h" ? "fail2ban" : "archive";
---
### TASK-023 — Replace loose `string` types with union types in config models
### TASK-023 — Replace loose `string` types with union types in config models (done)
**Where fixed:** `frontend/src/types/config.ts`, `frontend/src/types/jail.ts`, `frontend/src/components/config/JailsTab.tsx`, `frontend/src/components/config/JailSectionPanel.tsx`, `backend/app/models/config.py`
**Summary:** Added explicit union types for `DNSMode`, `LogEncoding`, and `BackendType` in frontend config models and backend Pydantic models. Updated the affected jail config form state and select handlers to use the narrowed types.
**Where found:** `frontend/src/types/config.ts``JailConfig.use_dns`, `JailConfig.log_encoding`, `JailConfig.backend`, and several action/filter fields are typed as plain `string` despite having a fixed set of valid values defined by fail2ban.
@@ -474,7 +478,11 @@ Update `JailConfig` (and the corresponding `JailConfigUpdate` patch type) to use
---
### TASK-024 — Add `useReducer` to `ServerTab` and `ConfFilesTab`
### TASK-024 — Add `useReducer` to `ServerTab` and `ConfFilesTab` (done)
**Status:** done
**Summary:** Replaced the local `useState` clusters in `ServerTab.tsx` and `ConfFilesTab.tsx` with reducer-based state management, ensuring compound updates such as flush/reload/restart transitions and file content edits update atomically.
**Where found:**
- `frontend/src/components/config/ServerTab.tsx` — 11 `useState` calls managing logLevel, logTarget, dbPurgeAge, dbMaxMatches, flushing, msg, isReloading, isRestarting, and three map threshold fields.

View File

@@ -6,7 +6,7 @@
* be reused for both filter and action files.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useReducer } from "react";
import {
Accordion,
AccordionHeader,
@@ -27,6 +27,88 @@ import { ApiError } from "../../api/client";
import type { ConfFileEntry } from "../../types/config";
import { useConfigStyles } from "./configStyles";
interface ConfFilesTabState {
files: ConfFileEntry[];
loading: boolean;
error: string | null;
openItems: string[];
contents: Record<string, string>;
editedContents: Record<string, string>;
saving: string | null;
msg: { text: string; ok: boolean } | null;
newName: string;
newContent: string;
creating: boolean;
}
type ConfFilesTabAction =
| { type: "setFiles"; value: ConfFileEntry[] }
| { type: "setLoading"; value: boolean }
| { type: "setError"; value: string | null }
| { type: "setOpenItems"; value: string[] }
| { type: "setContent"; name: string; content: string }
| { type: "setEditedContent"; name: string; content: string }
| { type: "setSaving"; value: string | null }
| { type: "setMsg"; value: { text: string; ok: boolean } | null }
| { type: "setNewName"; value: string }
| { type: "setNewContent"; value: string }
| { type: "setCreating"; value: boolean };
export const initialConfFilesTabState: ConfFilesTabState = {
files: [],
loading: true,
error: null,
openItems: [],
contents: {},
editedContents: {},
saving: null,
msg: null,
newName: "",
newContent: "",
creating: false,
};
export function confFilesTabReducer(
state: ConfFilesTabState,
action: ConfFilesTabAction,
): ConfFilesTabState {
switch (action.type) {
case "setFiles":
return { ...state, files: action.value };
case "setLoading":
return { ...state, loading: action.value };
case "setError":
return { ...state, error: action.value };
case "setOpenItems":
return { ...state, openItems: action.value };
case "setContent":
return {
...state,
contents: { ...state.contents, [action.name]: action.content },
};
case "setEditedContent":
return {
...state,
editedContents: {
...state.editedContents,
[action.name]: action.content,
},
};
case "setSaving":
return { ...state, saving: action.value };
case "setMsg":
return { ...state, msg: action.value };
case "setNewName":
return { ...state, newName: action.value };
case "setNewContent":
return { ...state, newContent: action.value };
case "setCreating":
return { ...state, creating: action.value };
default:
return state;
}
}
export interface ConfFilesTabProps {
/** Human-readable label, e.g. "Filter" or "Action". */
label: string;
@@ -61,37 +143,43 @@ export function ConfFilesTab({
createFile,
}: ConfFilesTabProps): React.JSX.Element {
const styles = useConfigStyles();
const [files, setFiles] = useState<ConfFileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openItems, setOpenItems] = useState<string[]>([]);
const [contents, setContents] = useState<Record<string, string>>({});
const [editedContents, setEditedContents] = useState<Record<string, string>>(
{},
const [state, dispatch] = useReducer(
confFilesTabReducer,
initialConfFilesTabState,
);
const [saving, setSaving] = useState<string | null>(null);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [newName, setNewName] = useState("");
const [newContent, setNewContent] = useState("");
const [creating, setCreating] = useState(false);
const {
files,
loading,
error,
openItems,
contents,
editedContents,
saving,
msg,
newName,
newContent,
creating,
} = state;
const loadFiles = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
dispatch({ type: "setLoading", value: true });
dispatch({ type: "setError", value: null });
try {
const resp = await fetchList();
if (signal?.aborted) return;
setFiles(resp.files);
dispatch({ type: "setFiles", value: resp.files });
} catch (err: unknown) {
if (signal?.aborted) return;
setError(
err instanceof ApiError
? err.message
: `Failed to load ${label.toLowerCase()} files.`,
);
dispatch({
type: "setError",
value:
err instanceof ApiError
? err.message
: `Failed to load ${label.toLowerCase()} files.`,
});
} finally {
if (!signal?.aborted) {
setLoading(false);
dispatch({ type: "setLoading", value: false });
}
}
}, [fetchList, label]);
@@ -111,23 +199,33 @@ export function ConfFilesTab({
) => {
const next = data.openItems as string[];
const newlyOpened = next.filter((v) => !openItems.includes(v));
setOpenItems(next);
dispatch({ type: "setOpenItems", value: next });
for (const name of newlyOpened) {
if (!Object.prototype.hasOwnProperty.call(contents, name)) {
void fetchFile(name)
.then((c) => {
setContents((prev) => ({ ...prev, [name]: c.content }));
setEditedContents((prev) => ({ ...prev, [name]: c.content }));
dispatch({
type: "setContent",
name,
content: c.content,
});
dispatch({
type: "setEditedContent",
name,
content: c.content,
});
})
.catch(() => {
setContents((prev) => ({
...prev,
[name]: "(failed to load)",
}));
setEditedContents((prev) => ({
...prev,
[name]: "(failed to load)",
}));
dispatch({
type: "setContent",
name,
content: "(failed to load)",
});
dispatch({
type: "setEditedContent",
name,
content: "(failed to load)",
});
});
}
}
@@ -137,20 +235,23 @@ export function ConfFilesTab({
const handleSave = useCallback(
async (name: string) => {
setSaving(name);
setMsg(null);
dispatch({ type: "setSaving", value: name });
dispatch({ type: "setMsg", value: null });
try {
const content = editedContents[name] ?? contents[name] ?? "";
await updateFile(name, { content });
setContents((prev) => ({ ...prev, [name]: content }));
setMsg({ text: `${name} saved.`, ok: true });
dispatch({ type: "setContent", name, content });
dispatch({ type: "setMsg", value: { text: `${name} saved.`, ok: true } });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Save failed.",
ok: false,
dispatch({
type: "setMsg",
value: {
text: err instanceof ApiError ? err.message : "Save failed.",
ok: false,
},
});
} finally {
setSaving(null);
dispatch({ type: "setSaving", value: null });
}
},
[editedContents, contents, updateFile],
@@ -159,31 +260,45 @@ export function ConfFilesTab({
const handleCreate = useCallback(async () => {
const name = newName.trim();
if (!name) return;
setCreating(true);
setMsg(null);
dispatch({ type: "setCreating", value: true });
dispatch({ type: "setMsg", value: null });
try {
const created = await createFile({ name, content: newContent });
setFiles((prev) => [
...prev,
{ name: created.name, filename: created.filename },
]);
setContents((prev) => ({ ...prev, [created.name]: created.content }));
setEditedContents((prev) => ({
...prev,
[created.name]: created.content,
}));
setNewName("");
setNewContent("");
setMsg({ text: `${created.filename} created.`, ok: true });
dispatch({
type: "setFiles",
value: [
...files,
{ name: created.name, filename: created.filename },
],
});
dispatch({
type: "setContent",
name: created.name,
content: created.content,
});
dispatch({
type: "setEditedContent",
name: created.name,
content: created.content,
});
dispatch({ type: "setNewName", value: "" });
dispatch({ type: "setNewContent", value: "" });
dispatch({
type: "setMsg",
value: { text: `${created.filename} created.`, ok: true },
});
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Create failed.",
ok: false,
dispatch({
type: "setMsg",
value: {
text: err instanceof ApiError ? err.message : "Create failed.",
ok: false,
},
});
} finally {
setCreating(false);
dispatch({ type: "setCreating", value: false });
}
}, [newName, newContent, createFile]);
}, [createFile, files, newContent, newName]);
if (loading) return <Spinner label={`Loading ${label.toLowerCase()} files…`} />;
if (error)
@@ -247,10 +362,11 @@ export function ConfFilesTab({
fontFamily: "monospace",
}}
onChange={(_e, d) => {
setEditedContents((prev) => ({
...prev,
[file.name]: d.value,
}));
dispatch({
type: "setEditedContent",
name: file.name,
content: d.value,
});
}}
/>
<div className={styles.buttonRow}>
@@ -293,7 +409,7 @@ export function ConfFilesTab({
placeholder={`e.g. my-${label.toLowerCase()}`}
className={styles.codeFont}
onChange={(_e, d) => {
setNewName(d.value);
dispatch({ type: "setNewName", value: d.value });
}}
/>
</Field>
@@ -308,7 +424,7 @@ export function ConfFilesTab({
fontFamily: "monospace",
}}
onChange={(_e, d) => {
setNewContent(d.value);
dispatch({ type: "setNewContent", value: d.value });
}}
/>
</Field>

View File

@@ -7,7 +7,7 @@
* health + log viewer.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer } from "react";
import {
Button,
Field,
@@ -36,6 +36,97 @@ import { useConfigStyles } from "./configStyles";
/** Available fail2ban log levels in descending severity order. */
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
interface ServerTabMessage {
text: string;
ok: boolean;
}
interface ServerTabState {
logLevel: string;
logTarget: string;
dbPurgeAge: string;
dbMaxMatches: string;
flushing: boolean;
msg: ServerTabMessage | null;
isReloading: boolean;
isRestarting: boolean;
mapThresholdHigh: string;
mapThresholdMedium: string;
mapThresholdLow: string;
mapValidPayload: MapColorThresholdsUpdate;
}
type ServerTabAction =
| { type: "setLogLevel"; value: string }
| { type: "setLogTarget"; value: string }
| { type: "setDbPurgeAge"; value: string }
| { type: "setDbMaxMatches"; value: string }
| { type: "setFlushing"; value: boolean }
| { type: "setMsg"; value: ServerTabMessage | null }
| { type: "setIsReloading"; value: boolean }
| { type: "setIsRestarting"; value: boolean }
| {
type: "setMapThresholds";
high: string;
medium: string;
low: string;
}
| { type: "setMapValidPayload"; payload: MapColorThresholdsUpdate };
export const initialServerTabState: ServerTabState = {
logLevel: "",
logTarget: "",
dbPurgeAge: "",
dbMaxMatches: "",
flushing: false,
msg: null,
isReloading: false,
isRestarting: false,
mapThresholdHigh: "",
mapThresholdMedium: "",
mapThresholdLow: "",
mapValidPayload: {
threshold_high: 0,
threshold_medium: 0,
threshold_low: 0,
},
};
export function serverTabReducer(
state: ServerTabState,
action: ServerTabAction,
): ServerTabState {
switch (action.type) {
case "setLogLevel":
return { ...state, logLevel: action.value };
case "setLogTarget":
return { ...state, logTarget: action.value };
case "setDbPurgeAge":
return { ...state, dbPurgeAge: action.value };
case "setDbMaxMatches":
return { ...state, dbMaxMatches: action.value };
case "setFlushing":
return { ...state, flushing: action.value };
case "setMsg":
return { ...state, msg: action.value };
case "setIsReloading":
return { ...state, isReloading: action.value };
case "setIsRestarting":
return { ...state, isRestarting: action.value };
case "setMapThresholds":
return {
...state,
mapThresholdHigh: action.high,
mapThresholdMedium: action.medium,
mapThresholdLow: action.low,
};
case "setMapValidPayload":
return { ...state, mapValidPayload: action.payload };
default:
return state;
}
}
/**
* Tab component for editing live fail2ban server settings.
*
@@ -45,27 +136,29 @@ export function ServerTab(): React.JSX.Element {
const styles = useConfigStyles();
const { settings, loading, error, updateSettings, flush, reload, restart } =
useServerSettings();
const [logLevel, setLogLevel] = useState("");
const [logTarget, setLogTarget] = useState("");
const [dbPurgeAge, setDbPurgeAge] = useState("");
const [dbMaxMatches, setDbMaxMatches] = useState("");
const [flushing, setFlushing] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [state, dispatch] = useReducer(serverTabReducer, initialServerTabState);
// Reload/Restart state
const [isReloading, setIsReloading] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const {
logLevel,
logTarget,
dbPurgeAge,
dbMaxMatches,
flushing,
msg,
isReloading,
isRestarting,
mapThresholdHigh,
mapThresholdMedium,
mapThresholdLow,
mapValidPayload,
} = state;
// Map color thresholds
const {
thresholds: mapThresholds,
error: mapThresholdsError,
refresh: refreshMapThresholds,
updateThresholds: updateMapThresholds,
} = useMapColorThresholds();
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
const [mapThresholdLow, setMapThresholdLow] = useState("");
const effectiveLogLevel = logLevel || settings?.log_level || "";
const effectiveLogTarget = logTarget || settings?.log_target || "";
@@ -78,8 +171,7 @@ export function ServerTab(): React.JSX.Element {
const update: ServerSettingsUpdate = {};
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
if (effectiveDbPurgeAge)
update.db_purge_age = Number(effectiveDbPurgeAge);
if (effectiveDbPurgeAge) update.db_purge_age = Number(effectiveDbPurgeAge);
if (effectiveDbMaxMatches)
update.db_max_matches = Number(effectiveDbMaxMatches);
return update;
@@ -89,62 +181,79 @@ export function ServerTab(): React.JSX.Element {
useAutoSave(updatePayload, updateSettings);
const handleFlush = useCallback(async () => {
setFlushing(true);
setMsg(null);
dispatch({ type: "setFlushing", value: true });
dispatch({ type: "setMsg", value: null });
try {
const result = await flush();
setMsg({ text: `Logs flushed: ${result}`, ok: true });
dispatch({ type: "setMsg", value: { text: `Logs flushed: ${result}`, ok: true } });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Flush failed.",
ok: false,
dispatch({
type: "setMsg",
value: {
text: err instanceof ApiError ? err.message : "Flush failed.",
ok: false,
},
});
} finally {
setFlushing(false);
dispatch({ type: "setFlushing", value: false });
}
}, [flush]);
const handleReload = async (): Promise<void> => {
setIsReloading(true);
setMsg(null);
const handleReload = useCallback(async (): Promise<void> => {
dispatch({ type: "setIsReloading", value: true });
dispatch({ type: "setMsg", value: null });
try {
await reload();
setMsg({ text: "fail2ban reloaded successfully", ok: true });
dispatch({
type: "setMsg",
value: { text: "fail2ban reloaded successfully", ok: true },
});
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Reload failed.",
ok: false,
dispatch({
type: "setMsg",
value: {
text: err instanceof ApiError ? err.message : "Reload failed.",
ok: false,
},
});
} finally {
setIsReloading(false);
dispatch({ type: "setIsReloading", value: false });
}
};
}, [reload]);
const handleRestart = async (): Promise<void> => {
setIsRestarting(true);
setMsg(null);
const handleRestart = useCallback(async (): Promise<void> => {
dispatch({ type: "setIsRestarting", value: true });
dispatch({ type: "setMsg", value: null });
try {
await restart();
setMsg({ text: "fail2ban restart initiated", ok: true });
dispatch({
type: "setMsg",
value: { text: "fail2ban restart initiated", ok: true },
});
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Restart failed.",
ok: false,
dispatch({
type: "setMsg",
value: {
text: err instanceof ApiError ? err.message : "Restart failed.",
ok: false,
},
});
} finally {
setIsRestarting(false);
dispatch({ type: "setIsRestarting", value: false });
}
};
}, [restart]);
useEffect(() => {
if (!mapThresholds) return;
setMapThresholdHigh(String(mapThresholds.threshold_high));
setMapThresholdMedium(String(mapThresholds.threshold_medium));
setMapThresholdLow(String(mapThresholds.threshold_low));
dispatch({
type: "setMapThresholds",
high: String(mapThresholds.threshold_high),
medium: String(mapThresholds.threshold_medium),
low: String(mapThresholds.threshold_low),
});
}, [mapThresholds]);
// Map threshold validation and auto-save.
const mapHigh = Number(mapThresholdHigh);
const mapMedium = Number(mapThresholdMedium);
const mapLow = Number(mapThresholdLow);
@@ -160,18 +269,15 @@ export function ServerTab(): React.JSX.Element {
return null;
}, [mapHigh, mapMedium, mapLow, mapThresholds]);
const [mapValidPayload, setMapValidPayload] = useState<MapColorThresholdsUpdate>({
threshold_high: mapThresholds?.threshold_high ?? 0,
threshold_medium: mapThresholds?.threshold_medium ?? 0,
threshold_low: mapThresholds?.threshold_low ?? 0,
});
useEffect(() => {
if (mapValidationError !== null || !mapThresholds) return;
setMapValidPayload({
threshold_high: mapHigh,
threshold_medium: mapMedium,
threshold_low: mapLow,
dispatch({
type: "setMapValidPayload",
payload: {
threshold_high: mapHigh,
threshold_medium: mapMedium,
threshold_low: mapLow,
},
});
}, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]);
@@ -224,7 +330,7 @@ export function ServerTab(): React.JSX.Element {
<Select
value={effectiveLogLevel}
onChange={(_e, d) => {
setLogLevel(d.value);
dispatch({ type: "setLogLevel", value: d.value });
}}
>
{LOG_LEVELS.map((l) => (
@@ -239,7 +345,7 @@ export function ServerTab(): React.JSX.Element {
value={effectiveLogTarget}
placeholder="STDOUT / /var/log/fail2ban.log"
onChange={(_e, d) => {
setLogTarget(d.value);
dispatch({ type: "setLogTarget", value: d.value });
}}
/>
</Field>
@@ -269,7 +375,7 @@ export function ServerTab(): React.JSX.Element {
type="number"
value={effectiveDbPurgeAge}
onChange={(_e, d) => {
setDbPurgeAge(d.value);
dispatch({ type: "setDbPurgeAge", value: d.value });
}}
/>
</Field>
@@ -281,7 +387,7 @@ export function ServerTab(): React.JSX.Element {
type="number"
value={effectiveDbMaxMatches}
onChange={(_e, d) => {
setDbMaxMatches(d.value);
dispatch({ type: "setDbMaxMatches", value: d.value });
}}
/>
</Field>
@@ -363,7 +469,7 @@ export function ServerTab(): React.JSX.Element {
type="number"
value={mapThresholdLow}
onChange={(_, d) => {
setMapThresholdLow(d.value);
dispatch({ type: "setMapThresholds", high: mapThresholdHigh, medium: mapThresholdMedium, low: d.value });
}}
min={1}
/>
@@ -373,7 +479,7 @@ export function ServerTab(): React.JSX.Element {
type="number"
value={mapThresholdMedium}
onChange={(_, d) => {
setMapThresholdMedium(d.value);
dispatch({ type: "setMapThresholds", high: mapThresholdHigh, medium: d.value, low: mapThresholdLow });
}}
min={1}
/>
@@ -383,7 +489,7 @@ export function ServerTab(): React.JSX.Element {
type="number"
value={mapThresholdHigh}
onChange={(_, d) => {
setMapThresholdHigh(d.value);
dispatch({ type: "setMapThresholds", high: d.value, medium: mapThresholdMedium, low: mapThresholdLow });
}}
min={1}
/>

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
confFilesTabReducer,
initialConfFilesTabState,
} from "../ConfFilesTab";
const sampleFile = { name: "example", filename: "example.conf" };
describe("confFilesTabReducer", () => {
it("sets files and open items", () => {
const state = confFilesTabReducer(initialConfFilesTabState, {
type: "setFiles",
value: [sampleFile],
});
expect(state.files).toEqual([sampleFile]);
const nextState = confFilesTabReducer(state, {
type: "setOpenItems",
value: ["example"],
});
expect(nextState.openItems).toEqual(["example"]);
});
it("stores content edits independently from loaded content", () => {
const state = confFilesTabReducer(initialConfFilesTabState, {
type: "setContent",
name: "example",
content: "original",
});
const nextState = confFilesTabReducer(state, {
type: "setEditedContent",
name: "example",
content: "modified",
});
expect(nextState.contents).toEqual({ example: "original" });
expect(nextState.editedContents).toEqual({ example: "modified" });
});
it("resets create form fields after creation", () => {
const state = confFilesTabReducer(initialConfFilesTabState, {
type: "setNewName",
value: "test",
});
const nextState = confFilesTabReducer(state, {
type: "setNewContent",
value: "content",
});
expect(nextState.newName).toBe("test");
expect(nextState.newContent).toBe("content");
const cleared = confFilesTabReducer(nextState, {
type: "setNewName",
value: "",
});
const clearedContent = confFilesTabReducer(cleared, {
type: "setNewContent",
value: "",
});
expect(clearedContent.newName).toBe("");
expect(clearedContent.newContent).toBe("");
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { initialServerTabState, serverTabReducer } from "../ServerTab";
describe("serverTabReducer", () => {
it("updates the log level", () => {
const nextState = serverTabReducer(initialServerTabState, {
type: "setLogLevel",
value: "DEBUG",
});
expect(nextState.logLevel).toBe("DEBUG");
});
it("toggles loading actions correctly", () => {
const startState = serverTabReducer(initialServerTabState, {
type: "setFlushing",
value: true,
});
expect(startState.flushing).toBe(true);
const endState = serverTabReducer(startState, {
type: "setFlushing",
value: false,
});
expect(endState.flushing).toBe(false);
});
it("updates map thresholds and valid payload together", () => {
const thresholdState = serverTabReducer(initialServerTabState, {
type: "setMapThresholds",
high: "100",
medium: "50",
low: "10",
});
expect(thresholdState.mapThresholdHigh).toBe("100");
expect(thresholdState.mapThresholdMedium).toBe("50");
expect(thresholdState.mapThresholdLow).toBe("10");
const payloadState = serverTabReducer(thresholdState, {
type: "setMapValidPayload",
payload: { threshold_high: 100, threshold_medium: 50, threshold_low: 10 },
});
expect(payloadState.mapValidPayload).toEqual({
threshold_high: 100,
threshold_medium: 50,
threshold_low: 10,
});
});
});