1import { expect, request, type APIRequestContext, type APIResponse } from "@playwright/test";
2import { siteHeader } from "./site";
3
4type Doc = Record<string, any>;
5
6/**
7 * Thin Frappe REST client bound to one logged-in user.
8 * The session never renders a page, so Frappe issues it no CSRF token and
9 * unsafe requests go through without one.
10 */
11export class Api {
12 constructor(private context: APIRequestContext) {}
13
14 static async login(baseURL: string, user: string, password: string) {
15 const context = await request.newContext({
16 baseURL,
17 extraHTTPHeaders: siteHeader(),
18 });
19 const response = await context.post("/api/method/login", {
20 form: { usr: user, pwd: password },
21 });
22 if (!response.ok()) throw new Error(`Login failed for ${user}`);
23 return new Api(context);
24 }
25
26 async call<T = any>(method: string, args: Doc = {}): Promise<T> {
27 const body = await this.send("post", `/api/method/${method}`, args);
28 return body.message;
29 }
30
31 /** For whitelisted methods that only accept GET. */
32 async callGet<T = any>(method: string, args: Record<string, string> = {}): Promise<T> {
33 return (await this.send("get", `/api/method/${method}?${new URLSearchParams(args)}`)).message;
34 }
35
36 async get<T = Doc>(doctype: string, name: string): Promise<T> {
37 return (await this.send("get", this.path(doctype, name))).data;
38 }
39
40 async insert<T = Doc>(doctype: string, doc: Doc): Promise<T> {
41 return (await this.send("post", this.path(doctype), doc)).data;
42 }
43
44 async update<T = Doc>(doctype: string, name: string, doc: Doc): Promise<T> {
45 return (await this.send("put", this.path(doctype, name), doc)).data;
46 }
47
48 async delete(doctype: string, name: string) {
49 await this.send("delete", this.path(doctype, name));
50 }
51
52 async exists(doctype: string, filters: Doc): Promise<string | undefined> {
53 const rows = await this.list(doctype, { filters, limit: 1 });
54 return rows[0]?.name;
55 }
56
57 async list<T = Doc>(
58 doctype: string,
59 options: { fields?: string[]; filters?: Doc; limit?: number } = {}
60 ): Promise<T[]> {
61 const params = new URLSearchParams({
62 fields: JSON.stringify(options.fields || ["name"]),
63 filters: JSON.stringify(options.filters || {}),
64 limit_page_length: String(options.limit || 0),
65 });
66 return (await this.send("get", `${this.path(doctype)}?${params}`)).data;
67 }
68
69 /** Raw request for tests that assert on failures (permission checks). */
70 raw(method: string, args: Doc = {}) {
71 return this.context.post(`/api/method/${method}`, { data: args });
72 }
73
74 private path(doctype: string, name?: string) {
75 const base = `/api/resource/${encodeURIComponent(doctype)}`;
76 return name ? `${base}/${encodeURIComponent(name)}` : base;
77 }
78
79 private async send(verb: "get" | "post" | "put" | "delete", url: string, data?: Doc) {
80 const response = await this.context[verb](url, data ? { data } : {});
81 if (!response.ok()) {
82 throw new Error(`${verb.toUpperCase()} ${url}: ${await response.text()}`);
83 }
84 return response.json();
85 }
86}
87
88/** Run a scheduled job now; it is enqueued, so callers poll for its effect. */
89export async function runScheduledJob(api: Api, method: string) {
90 const [job] = await api.list("Scheduled Job Type", { filters: { method } });
91 await api.call("frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", {
92 doc: JSON.stringify({ name: job.name }),
93 });
94}
95
96/** Assert a raw request was refused for lack of permission. */
97export async function expectDenied(pending: Promise<APIResponse>) {
98 const response = await pending;
99 const body = await response.text();
100 expect(response.ok(), body).toBeFalsy();
101 expect(body).toMatch(/PermissionError|not permitted|Insufficient Permission/i);
102}
103