mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(config): valibot schemas, schema version, migrations, filter AST
This commit is contained in:
parent
eaec039ad4
commit
2470bd786d
13 changed files with 617 additions and 191 deletions
170
tests/filter-ast.test.ts
Normal file
170
tests/filter-ast.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { FilterNode, WebhookEvent } from "../server/lib/types";
|
||||
import {
|
||||
evaluateFilterNode,
|
||||
containsKeyword,
|
||||
explainFilter,
|
||||
explainFilterNode,
|
||||
} from "../server/lib/events/filter-ast";
|
||||
import { matchRoute } from "../server/lib/events/match";
|
||||
|
||||
function event(overrides: Partial<WebhookEvent> = {}): WebhookEvent {
|
||||
return {
|
||||
event: "pull_request",
|
||||
payload: {
|
||||
repository: { full_name: "acme/widget" },
|
||||
sender: { login: "alice" },
|
||||
action: "opened",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("filter-ast evaluateFilterNode", () => {
|
||||
test("leaf event matches", () => {
|
||||
expect(evaluateFilterNode({ type: "event", match: "pull_request" }, event())).toBe(true);
|
||||
});
|
||||
|
||||
test("leaf repo mismatch", () => {
|
||||
expect(evaluateFilterNode({ type: "repo", match: "other/repo" }, event())).toBe(false);
|
||||
});
|
||||
|
||||
test("all requires every child", () => {
|
||||
const node: FilterNode = {
|
||||
all: [
|
||||
{ type: "event", match: "pull_request" },
|
||||
{ type: "repo", match: "acme/widget" },
|
||||
],
|
||||
};
|
||||
expect(evaluateFilterNode(node, event())).toBe(true);
|
||||
});
|
||||
|
||||
test("all fails when one child fails", () => {
|
||||
const node: FilterNode = {
|
||||
all: [
|
||||
{ type: "event", match: "pull_request" },
|
||||
{ type: "repo", match: "nope/repo" },
|
||||
],
|
||||
};
|
||||
expect(evaluateFilterNode(node, event())).toBe(false);
|
||||
});
|
||||
|
||||
test("any passes when one child matches", () => {
|
||||
const node: FilterNode = {
|
||||
any: [
|
||||
{ type: "repo", match: "nope/repo" },
|
||||
{ type: "event", match: "pull_request" },
|
||||
],
|
||||
};
|
||||
expect(evaluateFilterNode(node, event())).toBe(true);
|
||||
});
|
||||
|
||||
test("not negates", () => {
|
||||
expect(evaluateFilterNode({ not: { type: "repo", match: "nope/repo" } }, event())).toBe(true);
|
||||
});
|
||||
|
||||
test("nested structure", () => {
|
||||
const node: FilterNode = {
|
||||
all: [
|
||||
{ type: "event", match: "pull_request" },
|
||||
{
|
||||
any: [
|
||||
{ type: "repo", match: "acme/*" },
|
||||
{ type: "repo", match: "x/*" },
|
||||
],
|
||||
},
|
||||
{ not: { type: "actor", match: "bob" } },
|
||||
],
|
||||
};
|
||||
expect(evaluateFilterNode(node, event())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter-ast containsKeyword", () => {
|
||||
test("detects keyword leaf", () => {
|
||||
expect(containsKeyword({ type: "keyword", match: "TODO" })).toBe(true);
|
||||
});
|
||||
|
||||
test("detects nested keyword", () => {
|
||||
expect(
|
||||
containsKeyword({
|
||||
all: [{ type: "event", match: "push" }, { not: { type: "keyword", match: "TODO" } }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("false without keyword", () => {
|
||||
expect(containsKeyword({ all: [{ type: "event", match: "push" }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter-ast explain", () => {
|
||||
test("explainFilter leaf", () => {
|
||||
expect(explainFilter({ type: "event", match: "push" })).toBe('event is "push"');
|
||||
});
|
||||
|
||||
test("explainFilter array match", () => {
|
||||
expect(explainFilter({ type: "repo", match: ["a/*", "b/*"] })).toBe('repo is "a/*" or "b/*"');
|
||||
});
|
||||
|
||||
test("explainFilter exclude", () => {
|
||||
expect(explainFilter({ type: "actor", match: "bob", exclude: true })).toBe(
|
||||
'not (actor is "bob")',
|
||||
);
|
||||
});
|
||||
|
||||
test("explainFilterNode all", () => {
|
||||
expect(
|
||||
explainFilterNode({
|
||||
all: [
|
||||
{ type: "event", match: "push" },
|
||||
{ type: "repo", match: "acme/*" },
|
||||
],
|
||||
}),
|
||||
).toBe('(event is "push" and repo is "acme/*")');
|
||||
});
|
||||
|
||||
test("explainFilterNode not", () => {
|
||||
expect(explainFilterNode({ not: { type: "event", match: "push" } })).toBe(
|
||||
'not (event is "push")',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchRoute AST integration", () => {
|
||||
const baseRoute = {
|
||||
id: "r1",
|
||||
name: "route",
|
||||
enabled: true,
|
||||
filters: [{ type: "event" as const, match: "pull_request" }],
|
||||
targets: [{ platform: "discord" as const, channelId: "c1" }],
|
||||
};
|
||||
|
||||
test("flat filters act as AND", () => {
|
||||
const route = {
|
||||
...baseRoute,
|
||||
filters: [
|
||||
{ type: "event" as const, match: "pull_request" },
|
||||
{ type: "repo" as const, match: "acme/widget" },
|
||||
],
|
||||
};
|
||||
expect(matchRoute(route, event())).toBe(true);
|
||||
});
|
||||
|
||||
test("ast overrides flat filters", () => {
|
||||
const route = {
|
||||
...baseRoute,
|
||||
ast: {
|
||||
any: [
|
||||
{ type: "repo" as const, match: "nope/*" },
|
||||
{ type: "event" as const, match: "pull_request" },
|
||||
],
|
||||
} as FilterNode,
|
||||
};
|
||||
expect(matchRoute(route, event())).toBe(true);
|
||||
});
|
||||
|
||||
test("disabled route never matches", () => {
|
||||
expect(matchRoute({ ...baseRoute, enabled: false }, event())).toBe(false);
|
||||
});
|
||||
});
|
||||
116
tests/schema.test.ts
Normal file
116
tests/schema.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import * as v from "valibot";
|
||||
import type { Group, Route } from "../server/lib/types";
|
||||
import {
|
||||
CONFIG_SCHEMA_VERSION,
|
||||
filterSchema,
|
||||
routeSchema,
|
||||
groupSchema,
|
||||
migrateRoutes,
|
||||
migrateGroups,
|
||||
validateRoutes,
|
||||
validateGroups,
|
||||
explainRoute,
|
||||
} from "../server/lib/config/schema";
|
||||
|
||||
const validRoute: Route = {
|
||||
id: "r1",
|
||||
name: "build",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ platform: "discord", channelId: "c1" }],
|
||||
};
|
||||
|
||||
describe("config schema", () => {
|
||||
test("version is defined", () => {
|
||||
expect(CONFIG_SCHEMA_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
test("filterSchema accepts valid filter", () => {
|
||||
expect(v.safeParse(filterSchema, { type: "repo", match: "a/*" }).success).toBe(true);
|
||||
});
|
||||
|
||||
test("filterSchema rejects bad type", () => {
|
||||
expect(v.safeParse(filterSchema, { type: "wat", match: "a" }).success).toBe(false);
|
||||
});
|
||||
|
||||
test("routeSchema accepts valid route", () => {
|
||||
expect(v.safeParse(routeSchema, validRoute).success).toBe(true);
|
||||
});
|
||||
|
||||
test("routeSchema accepts ast filter node", () => {
|
||||
const route = {
|
||||
...validRoute,
|
||||
ast: { any: [{ type: "event" as const, match: "push" }] },
|
||||
};
|
||||
expect(v.safeParse(routeSchema, route).success).toBe(true);
|
||||
});
|
||||
|
||||
test("routeSchema rejects missing enabled", () => {
|
||||
const { enabled: _e, ...rest } = validRoute;
|
||||
expect(v.safeParse(routeSchema, rest).success).toBe(false);
|
||||
});
|
||||
|
||||
test("groupSchema accepts valid group", () => {
|
||||
const group: Group = { id: "g1", name: "team", adminIds: ["a"] };
|
||||
expect(v.safeParse(groupSchema, group).success).toBe(true);
|
||||
});
|
||||
|
||||
test("groupSchema rejects bad role", () => {
|
||||
const group: Group = {
|
||||
id: "g1",
|
||||
name: "team",
|
||||
adminIds: [],
|
||||
members: [{ login: "a", role: "super" as never }],
|
||||
};
|
||||
expect(v.safeParse(groupSchema, group).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrations", () => {
|
||||
test("migrateRoutes converts legacy target to targets", () => {
|
||||
const legacy = { id: "r1", name: "x", enabled: true, filters: [], target: { channelId: "c1" } };
|
||||
const out = migrateRoutes([legacy as unknown as Route]);
|
||||
expect(out[0].targets).toEqual([{ channelId: "c1" }]);
|
||||
});
|
||||
|
||||
test("migrateRoutes leaves targets array untouched", () => {
|
||||
const out = migrateRoutes([validRoute]);
|
||||
expect(out[0]).toEqual(validRoute);
|
||||
});
|
||||
|
||||
test("migrateGroups is a no-op", () => {
|
||||
const groups: Group[] = [{ id: "g1", name: "team", adminIds: ["a"] }];
|
||||
expect(migrateGroups(groups)).toEqual(groups);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation is non-destructive", () => {
|
||||
test("validateRoutes returns all entries even when invalid", () => {
|
||||
const routes = [validRoute, { id: "bad", name: "x", filters: [] } as unknown as Route];
|
||||
const out = validateRoutes(routes);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("validateGroups returns all entries even when invalid", () => {
|
||||
const groups = [
|
||||
{ id: "g1", name: "team", adminIds: ["a"] },
|
||||
{ id: "bad", name: "x" } as unknown as Group,
|
||||
];
|
||||
expect(validateGroups(groups)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explainRoute", () => {
|
||||
test("explains filters", () => {
|
||||
expect(explainRoute(validRoute)).toBe('build: (event is "push")');
|
||||
});
|
||||
|
||||
test("explains ast", () => {
|
||||
const route = {
|
||||
...validRoute,
|
||||
ast: { any: [{ type: "event" as const, match: "push" }] },
|
||||
};
|
||||
expect(explainRoute(route)).toBe('build: (event is "push")');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue