fix(ui): подготовить проверку срезов статистики для FilterQuery
This commit is contained in:
1 parent
fca42aaa81
commit
52af82af42
3 files changed
+122
No files matched your search
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
|
||||
import { filterQueryToSlices, slicesToFilterQuery } from "./statistics-filter-query"
|
||||
|
||||
const rule = (field: string, value?: unknown, operator = "is") => createFilterRule({ id: field, path: [field], operator, value })
|
||||
|
||||
test("URL slices round-trip without dropping dimensions", () => {
|
||||
const slices = { country: "RU", service: "https", asn: "64512", serverId: "1", userId: "user-1", iface: "ether1" }
|
||||
assert.deepEqual(filterQueryToSlices(slicesToFilterQuery(slices)), { status: "valid", slices })
|
||||
assert.deepEqual(filterQueryToSlices(createFilterQuery()), { status: "valid", slices: {} })
|
||||
})
|
||||
|
||||
test("nested AND preserves all dimensions and normalizes values", () => {
|
||||
const query = createFilterQuery([rule("country", " ru "), createFilterQuery([rule("asn", "064512"), rule("service", " dns ")])])
|
||||
assert.deepEqual(filterQueryToSlices(query), { status: "valid", slices: { country: "RU", asn: "64512", service: "dns" } })
|
||||
})
|
||||
|
||||
test("unfinished edits never publish partial slices", () => {
|
||||
for (const value of [undefined, "", " "]) {
|
||||
assert.deepEqual(filterQueryToSlices(createFilterQuery([rule("country", "RU"), rule("service", value)])), { status: "draft" })
|
||||
}
|
||||
})
|
||||
|
||||
test("OR, negation, unsupported operators and conflicting equalities are rejected", () => {
|
||||
for (const query of [
|
||||
createFilterQuery([rule("country", "RU"), rule("country", "DE")], "or"),
|
||||
createFilterQuery([{ ...rule("country", "RU"), negated: true }]),
|
||||
createFilterQuery([rule("service", "http", "contains")]),
|
||||
createFilterQuery([rule("country", "RU"), rule("country", "DE")]),
|
||||
createFilterQuery([rule("country", ["RU", "DE"])]),
|
||||
createFilterQuery([rule("unknown", "value")]),
|
||||
]) assert.equal(filterQueryToSlices(query).status, "invalid")
|
||||
})
|
||||
|
||||
test("invalid values are rejected instead of silently rewritten", () => {
|
||||
for (const [key, value] of [["country", "RUS"], ["asn", "AS64512"], ["serverId", "-1"], ["serverId", "1.2"], ["asn", "9007199254740992"]]) {
|
||||
assert.equal(filterQueryToSlices(createFilterQuery([rule(key, value)])).status, "invalid")
|
||||
}
|
||||
assert.deepEqual(filterQueryToSlices(createFilterQuery([rule("country", "ru"), rule("country", "RU")])), { status: "valid", slices: { country: "RU" } })
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createFilterQuery, createFilterRule } from "@/components/reui/filters/filters-query"
|
||||
import type { FilterNode, FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
|
||||
export const STATISTICS_SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||
export type StatisticsSlices = Partial<Record<typeof STATISTICS_SLICE_KEYS[number], string>>
|
||||
export type StatisticsFilterResult =
|
||||
| { status: "valid"; slices: StatisticsSlices }
|
||||
| { status: "draft" }
|
||||
| { status: "invalid"; error: string }
|
||||
|
||||
export function slicesToFilterQuery(slices: StatisticsSlices): FilterQuery {
|
||||
return createFilterQuery(STATISTICS_SLICE_KEYS.flatMap(key => slices[key]
|
||||
? [createFilterRule({ id: key, path: [key], operator: "is", value: slices[key] })] : []))
|
||||
}
|
||||
|
||||
/** The statistics API accepts a conjunction of scalar equalities only.
|
||||
* Never publish a partial draft or flatten OR into an unrelated API request. */
|
||||
export function filterQueryToSlices(query: FilterQuery): StatisticsFilterResult {
|
||||
const slices: StatisticsSlices = {}
|
||||
let draft = false
|
||||
let error: string | undefined
|
||||
function visit(node: FilterNode) {
|
||||
if (node.type === "group") {
|
||||
if (node.combinator !== "and") {
|
||||
error = "Статистика поддерживает только условия, объединённые «И»."
|
||||
return
|
||||
}
|
||||
node.rules.forEach(visit)
|
||||
return
|
||||
}
|
||||
const key = node.path[0] as keyof StatisticsSlices
|
||||
if (node.path.length !== 1 || !STATISTICS_SLICE_KEYS.includes(key)) {
|
||||
error = "Неизвестное поле среза статистики."
|
||||
return
|
||||
}
|
||||
if (node.negated || (node.operator && node.operator !== "is")) {
|
||||
error = "Для срезов статистики доступно только условие «равно»."
|
||||
return
|
||||
}
|
||||
if (!node.operator || node.value === undefined || node.value === "") {
|
||||
draft = true
|
||||
return
|
||||
}
|
||||
if (typeof node.value !== "string") {
|
||||
error = "Укажите одно текстовое значение среза."
|
||||
return
|
||||
}
|
||||
let value = node.value.trim()
|
||||
if (!value) { draft = true; return }
|
||||
if (key === "country") {
|
||||
if (!/^[a-z]{2}$/i.test(value)) { error = "Укажите двухбуквенный код страны."; return }
|
||||
value = value.toUpperCase()
|
||||
}
|
||||
if (key === "asn" || key === "serverId") {
|
||||
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) {
|
||||
error = "ASN и идентификатор сервера должны быть положительными целыми числами."
|
||||
return
|
||||
}
|
||||
value = String(Number(value))
|
||||
}
|
||||
if (slices[key] !== undefined && slices[key] !== value) {
|
||||
error = "Для одного поля нельзя задать два разных значения среза."
|
||||
return
|
||||
}
|
||||
slices[key] = value
|
||||
}
|
||||
visit(query)
|
||||
if (error) return { status: "invalid", error }
|
||||
if (draft) return { status: "draft" }
|
||||
return { status: "valid", slices }
|
||||
}
|
||||
Reference in new issue
Block a user