Skip to content

Commit 0be9161

Browse files
committed
fix: implement setters, descriptors and more Proxy traps
The implementation of octokit#622 only implemented a `get` Proxy trap. This worked fine for the simple use-case of invoking the methods, but failed when users tried to mock functions with Jest or Sinon. It also did not list all functions anymore, when pressing tab-tab in a REPL. This commit implements further Proxy traps which are required for those use-cases and it uses the cache object for mutating operations. Fixes: octokit#683
1 parent d39cea9 commit 0be9161

File tree

4 files changed

+257
-7
lines changed

4 files changed

+257
-7
lines changed

package-lock.json

Lines changed: 122 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@types/fetch-mock": "^7.3.1",
3333
"@types/jest": "^29.0.0",
3434
"@types/node": "^18.0.0",
35+
"@types/sinon": "^10.0.19",
3536
"esbuild": "^0.19.0",
3637
"fetch-mock": "npm:@gr2m/fetch-mock@^9.11.0-pull-request-644.1",
3738
"github-openapi-graphql-query": "^4.0.0",
@@ -44,6 +45,7 @@
4445
"npm-run-all": "^4.1.5",
4546
"prettier": "3.0.3",
4647
"semantic-release-plugin-update-version-in-files": "^1.0.0",
48+
"sinon": "^16.1.0",
4749
"sort-keys": "^5.0.0",
4850
"string-to-jsdoc-comment": "^1.0.0",
4951
"ts-jest": "^29.0.0",

src/endpoints-to-methods.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,46 @@ type ProxyTarget = {
4848
};
4949

5050
const handler = {
51+
has({ scope }: ProxyTarget, methodName: string) {
52+
return endpointMethodsMap.get(scope).has(methodName);
53+
},
54+
getOwnPropertyDescriptor(target: ProxyTarget, methodName: string) {
55+
return {
56+
value: this.get(target, methodName), // ensures method is in the cache
57+
configurable: true,
58+
writable: true,
59+
enumerable: true,
60+
};
61+
},
62+
defineProperty(
63+
target: ProxyTarget,
64+
methodName: string,
65+
descriptor: PropertyDescriptor,
66+
) {
67+
Object.defineProperty(target.cache, methodName, descriptor);
68+
return true;
69+
},
70+
deleteProperty(target: ProxyTarget, methodName: string) {
71+
delete target.cache[methodName];
72+
return true;
73+
},
74+
ownKeys({ scope }: ProxyTarget) {
75+
return [...endpointMethodsMap.get(scope).keys()];
76+
},
77+
set(target: ProxyTarget, methodName: string, value: any) {
78+
return (target.cache[methodName] = value);
79+
},
5180
get({ octokit, scope, cache }: ProxyTarget, methodName: string) {
5281
if (cache[methodName]) {
5382
return cache[methodName];
5483
}
5584

56-
const { decorations, endpointDefaults } = endpointMethodsMap
57-
.get(scope)
58-
.get(methodName);
85+
const method = endpointMethodsMap.get(scope).get(methodName);
86+
if (!method) {
87+
return undefined;
88+
}
89+
90+
const { endpointDefaults, decorations } = method;
5991

6092
if (decorations) {
6193
cache[methodName] = decorate(

test/rest-endpoint-methods.test.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import fetchMock from "fetch-mock";
21
import { Octokit } from "@octokit/core";
2+
import fetchMock from "fetch-mock";
33

4-
import { restEndpointMethods, legacyRestEndpointMethods } from "../src";
4+
import sinon from "sinon";
5+
import { legacyRestEndpointMethods, restEndpointMethods } from "../src";
6+
import { Api } from "../src/types";
57

68
describe("REST API endpoint methods", () => {
79
it("README example", async () => {
@@ -175,6 +177,100 @@ describe("REST API endpoint methods", () => {
175177
return octokit.rest.apps.listInstallations();
176178
});
177179

180+
describe("mocking", () => {
181+
let octokit: Octokit & Api;
182+
183+
beforeEach(() => {
184+
const networkMock = fetchMock
185+
.sandbox()
186+
.getOnce(
187+
"https://api.github.com/repos/octokit/plugin-rest-endpoint-methods/issues/1/labels",
188+
[{ name: "mocked from network" }],
189+
);
190+
191+
const MyOctokit = Octokit.plugin(restEndpointMethods);
192+
octokit = new MyOctokit({
193+
request: {
194+
fetch: networkMock,
195+
},
196+
});
197+
});
198+
199+
afterEach(async () => {
200+
const restoredResult = await octokit.rest.issues.listLabelsOnIssue({
201+
owner: "octokit",
202+
repo: "plugin-rest-endpoint-methods",
203+
issue_number: 1,
204+
});
205+
expect(restoredResult.data[0].name).toBe("mocked from network");
206+
});
207+
208+
it("allows mocking with sinon.stub", async () => {
209+
const stub = sinon
210+
.stub(octokit.rest.issues, "listLabelsOnIssue")
211+
.resolves({ data: [{ name: "mocked from sinon" }] } as Awaited<
212+
ReturnType<typeof octokit.rest.issues.listLabelsOnIssue>
213+
>);
214+
215+
const sinonResult = await octokit.rest.issues.listLabelsOnIssue({
216+
owner: "octokit",
217+
repo: "plugin-rest-endpoint-methods",
218+
issue_number: 1,
219+
});
220+
expect(sinonResult.data[0].name).toBe("mocked from sinon");
221+
222+
stub.restore();
223+
});
224+
225+
it("allows mocking with jest.spyOn", async () => {
226+
jest
227+
.spyOn(octokit.rest.issues, "listLabelsOnIssue")
228+
.mockResolvedValueOnce({
229+
data: [{ name: "mocked from jest" }],
230+
} as Awaited<ReturnType<typeof octokit.rest.issues.listLabelsOnIssue>>);
231+
232+
const jestResult = await octokit.rest.issues.listLabelsOnIssue({
233+
owner: "octokit",
234+
repo: "plugin-rest-endpoint-methods",
235+
issue_number: 1,
236+
});
237+
expect(jestResult.data[0].name).toBe("mocked from jest");
238+
239+
jest.restoreAllMocks();
240+
});
241+
242+
it("allows manually replacing a method", async () => {
243+
const oldImplementation = octokit.rest.issues.listLabelsOnIssue;
244+
245+
octokit.rest.issues.listLabelsOnIssue = (async () => {
246+
return {
247+
data: [{ name: "mocked from custom implementation" }],
248+
} as Awaited<ReturnType<typeof octokit.rest.issues.listLabelsOnIssue>>;
249+
}) as unknown as typeof oldImplementation;
250+
251+
const customResult = await octokit.rest.issues.listLabelsOnIssue({
252+
owner: "octokit",
253+
repo: "plugin-rest-endpoint-methods",
254+
issue_number: 1,
255+
});
256+
expect(customResult.data[0].name).toBe(
257+
"mocked from custom implementation",
258+
);
259+
260+
delete (octokit.rest.issues as any).listLabelsOnIssue;
261+
octokit.rest.issues.listLabelsOnIssue = oldImplementation;
262+
});
263+
});
264+
265+
it("lists all methods (e.g. with tab-tab in a REPL)", async () => {
266+
const MyOctokit = Octokit.plugin(restEndpointMethods);
267+
const octokit = new MyOctokit();
268+
269+
const methods = Object.keys(octokit.rest.issues);
270+
271+
expect(methods).toContain("listLabelsOnIssue");
272+
});
273+
178274
// besides setting `octokit.rest.*`, the plugin exports legacyRestEndpointMethods
179275
// which is also setting the same methods directly on `octokit.*` for legacy reasons.
180276
// We will deprecate the `octokit.*` methods in future, but for now we just make sure they are set

0 commit comments

Comments
 (0)