Skip to content

Commit 8097748

Browse files
author
Anatoly Ostrovsky
committed
Additional tests
1 parent 2d36da3 commit 8097748

2 files changed

Lines changed: 317 additions & 14 deletions

File tree

src/services/rest/rest.spec.js

Lines changed: 314 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,317 @@
1+
import { $injectTokens } from "../../injection-tokens.ts";
2+
import { RestProvider, RestService } from "./rest.ts";
3+
import { expandExpression, expandUriTemplate, pctEncode } from "./rfc.ts";
4+
5+
function httpResponse(data, overrides = {}) {
6+
return {
7+
data,
8+
status: 200,
9+
headers: () => ({}),
10+
config: {},
11+
statusText: "OK",
12+
xhrStatus: "complete",
13+
...overrides,
14+
};
15+
}
16+
17+
class UserEntity {
18+
constructor(data) {
19+
this.id = data.id;
20+
this.name = data.name;
21+
this.mapped = true;
22+
}
23+
}
24+
125
describe("$rest", () => {
2-
it("test", () => {
3-
expect(true).toBeTruthy();
26+
describe("RestService", () => {
27+
let $http;
28+
29+
beforeEach(() => {
30+
$http = jasmine.createSpy("$http");
31+
});
32+
33+
it("requires a non-empty baseUrl", () => {
34+
expect(() => new RestService($http, "")).toThrowError(
35+
Error,
36+
"baseUrl required",
37+
);
38+
});
39+
40+
it("builds URLs from RFC templates", () => {
41+
const service = new RestService($http, "/users");
42+
43+
expect(service.buildUrl("/users/{id}{?q}", { id: 10, q: "a b" })).toBe(
44+
"/users/10?q=a%20b",
45+
);
46+
expect(service.buildUrl("/users/{id}", null)).toBe("/users/");
47+
});
48+
49+
it("lists entities, maps them, and forwards request options", async () => {
50+
$http.and.resolveTo(httpResponse([{ id: 1, name: "Ada" }]));
51+
const service = new RestService($http, "/users{?page}", UserEntity, {
52+
headers: { Accept: "application/json" },
53+
timeout: 25,
54+
});
55+
56+
const items = await service.list({ page: 2 });
57+
58+
expect(items.length).toBe(1);
59+
expect(items[0] instanceof UserEntity).toBeTrue();
60+
expect(items[0]).toEqual(
61+
jasmine.objectContaining({ id: 1, mapped: true }),
62+
);
63+
expect($http).toHaveBeenCalledWith({
64+
method: "GET",
65+
url: "/users?page=2",
66+
data: null,
67+
params: { page: 2 },
68+
headers: { Accept: "application/json" },
69+
timeout: 25,
70+
});
71+
});
72+
73+
it("returns an empty list when the backend payload is not an array", async () => {
74+
$http.and.resolveTo(httpResponse({ items: [] }));
75+
const service = new RestService($http, "/users");
76+
77+
await expectAsync(service.list()).toBeResolvedTo([]);
78+
});
79+
80+
it("reads entities and preserves falsy non-null payloads", async () => {
81+
$http.and.resolveTo(httpResponse(0));
82+
const service = new RestService($http, "/users");
83+
84+
await expectAsync(service.read(7)).toBeResolvedTo(0);
85+
expect($http).toHaveBeenCalledWith({
86+
method: "GET",
87+
url: "/users/7",
88+
data: null,
89+
params: {},
90+
});
91+
});
92+
93+
it("maps read results to entity instances and returns null for nullish bodies", async () => {
94+
$http.and.returnValues(
95+
Promise.resolve(httpResponse({ id: 7, name: "Grace" })),
96+
Promise.resolve(httpResponse(undefined)),
97+
);
98+
const service = new RestService($http, "/users", UserEntity);
99+
100+
const entity = await service.read(7);
101+
const missing = await service.read(8);
102+
103+
expect(entity instanceof UserEntity).toBeTrue();
104+
expect(entity.name).toBe("Grace");
105+
expect(missing).toBeNull();
106+
});
107+
108+
it("validates read, create, update, and delete arguments", async () => {
109+
const service = new RestService($http, "/users");
110+
111+
await expectAsync(service.read(null)).toBeRejectedWithError(
112+
Error,
113+
"badarg:id null",
114+
);
115+
await expectAsync(service.create(null)).toBeRejectedWithError(
116+
Error,
117+
"badarg:item null",
118+
);
119+
await expectAsync(service.update(undefined, {})).toBeRejectedWithError(
120+
Error,
121+
"badarg:id undefined",
122+
);
123+
await expectAsync(service.delete(undefined)).toBeRejectedWithError(
124+
Error,
125+
"badarg:id undefined",
126+
);
127+
});
128+
129+
it("creates entities and returns mapped data", async () => {
130+
$http.and.resolveTo(httpResponse({ id: 3, name: "Lin" }));
131+
const service = new RestService($http, "/users", UserEntity);
132+
133+
const created = await service.create({ name: "Lin" });
134+
135+
expect(created instanceof UserEntity).toBeTrue();
136+
expect($http).toHaveBeenCalledWith({
137+
method: "POST",
138+
url: "/users",
139+
data: { name: "Lin" },
140+
params: {},
141+
});
142+
});
143+
144+
it("returns raw truthy payloads when no entity class is configured", async () => {
145+
const raw = { id: 9, name: "Raw" };
146+
147+
$http.and.resolveTo(httpResponse(raw));
148+
const service = new RestService($http, "/users");
149+
150+
await expectAsync(service.create({ name: "Raw" })).toBeResolvedTo(raw);
151+
});
152+
153+
it("updates entities and swallows request failures as null", async () => {
154+
$http.and.returnValues(
155+
Promise.resolve(httpResponse({ id: 3, name: "Updated" })),
156+
Promise.reject(new Error("write failed")),
157+
);
158+
const service = new RestService($http, "/users", UserEntity);
159+
160+
const updated = await service.update(3, { name: "Updated" });
161+
const failed = await service.update(4, { name: "Nope" });
162+
163+
expect(updated instanceof UserEntity).toBeTrue();
164+
expect(updated.name).toBe("Updated");
165+
expect(failed).toBeNull();
166+
});
167+
168+
it("returns null when update succeeds with a nullish body", async () => {
169+
$http.and.resolveTo(httpResponse(undefined));
170+
const service = new RestService($http, "/users", UserEntity);
171+
172+
await expectAsync(service.update(3, { name: "Updated" })).toBeResolvedTo(
173+
null,
174+
);
175+
});
176+
177+
it("deletes entities and converts failures to false", async () => {
178+
$http.and.returnValues(
179+
Promise.resolve(
180+
httpResponse(null, { status: 204, statusText: "No Content" }),
181+
),
182+
Promise.reject(new Error("delete failed")),
183+
);
184+
const service = new RestService($http, "/users");
185+
186+
await expectAsync(service.delete(1)).toBeResolvedTo(true);
187+
await expectAsync(service.delete(2)).toBeResolvedTo(false);
188+
});
189+
});
190+
191+
describe("RestProvider", () => {
192+
it("exposes the expected injection token and factory", async () => {
193+
const provider = new RestProvider();
194+
const $http = jasmine.createSpy("$http").and.resolveTo(httpResponse([]));
195+
196+
provider.rest("users", "/users{?page}", UserEntity, { cache: true });
197+
198+
expect(provider.$get[0]).toBe($injectTokens._http);
199+
200+
const factory = provider.$get[1]($http);
201+
const service = factory("/admins", UserEntity, { timeout: 10 });
202+
203+
expect(typeof factory).toBe("function");
204+
expect(service instanceof RestService).toBeTrue();
205+
await expectAsync(service.list({ page: 1 })).toBeResolvedTo([]);
206+
});
207+
208+
it("supports provider and factory defaults when entityClass and options are omitted", async () => {
209+
const provider = new RestProvider();
210+
const raw = { id: 5, title: "Post" };
211+
const $http = jasmine.createSpy("$http").and.resolveTo(httpResponse(raw));
212+
213+
provider.rest("posts", "/posts");
214+
215+
const factory = provider.$get[1]($http);
216+
const service = factory("/posts");
217+
218+
await expectAsync(service.read(5)).toBeResolvedTo(raw);
219+
});
220+
});
221+
});
222+
223+
describe("RFC 6570 helpers", () => {
224+
describe("pctEncode", () => {
225+
it("encodes reserved characters by default and can preserve them", () => {
226+
expect(pctEncode("a/b?c", false)).toBe("a%2Fb%3Fc");
227+
expect(pctEncode("a/b?c", true)).toBe("a/b?c");
228+
});
229+
});
230+
231+
describe("expandUriTemplate", () => {
232+
it("throws when the template is not a string", () => {
233+
expect(() => expandUriTemplate(123)).toThrowError(
234+
TypeError,
235+
"template must be a string",
236+
);
237+
});
238+
239+
it("expands simple, query, path, fragment, and reserved expressions", () => {
240+
expect(expandUriTemplate("/users/{id}", { id: 10 })).toBe("/users/10");
241+
expect(
242+
expandUriTemplate("/search{?q,lang}", { q: "a b", lang: "en" }),
243+
).toBe("/search?q=a%20b&lang=en");
244+
expect(expandUriTemplate("{/segments*}", { segments: ["a", "b"] })).toBe(
245+
"/a/b",
246+
);
247+
expect(expandUriTemplate("{#path}", { path: "/docs" })).toBe("#/docs");
248+
expect(expandUriTemplate("{+path}", { path: "/docs?q=1" })).toBe(
249+
"/docs?q=1",
250+
);
251+
});
252+
});
253+
254+
describe("expandExpression", () => {
255+
it("returns an empty string when nothing can be expanded", () => {
256+
expect(expandExpression("id", {})).toBe("");
257+
expect(expandExpression("?id", { id: null })).toBe("");
258+
});
259+
260+
it("supports explode and prefix modifiers for scalars and arrays", () => {
261+
expect(expandExpression("name:3", { name: "angular" })).toBe("ang");
262+
expect(expandExpression("list", { list: ["red", "blue"] })).toBe(
263+
"red,blue",
264+
);
265+
expect(expandExpression("?list", { list: ["red", "blue"] })).toBe(
266+
"?list=red,blue",
267+
);
268+
expect(expandExpression("?list*", { list: ["red", null, "blue"] })).toBe(
269+
"?list=red&list=blue",
270+
);
271+
expect(expandExpression(";list", { list: [] })).toBe(";list");
272+
expect(expandExpression("?list", { list: [] })).toBe("?list=");
273+
expect(expandExpression("?list", { list: [null, undefined] })).toBe(
274+
"?list=",
275+
);
276+
expect(expandExpression(";list", { list: [null] })).toBe(";list");
277+
});
278+
279+
it("supports object values with exploded and non-exploded forms", () => {
280+
expect(expandExpression("coords", { coords: { lat: 10, lng: 20 } })).toBe(
281+
"lat,10,lng,20",
282+
);
283+
expect(
284+
expandExpression("?coords", { coords: { lat: 10, lng: 20 } }),
285+
).toBe("?coords=lat,10,lng,20");
286+
expect(
287+
expandExpression("?coords*", { coords: { lat: 10, lng: 20 } }),
288+
).toBe("?lat=10&lng=20");
289+
expect(
290+
expandExpression("coords*", { coords: { lat: 10, lng: 20 } }),
291+
).toBe("lat=10,lng=20");
292+
expect(expandExpression("?coords", { coords: {} })).toBe("?coords=");
293+
expect(expandExpression(";coords", { coords: {} })).toBe(";coords");
294+
expect(
295+
expandExpression("?coords*", { coords: { lat: null, lng: 20 } }),
296+
).toBe("?lng=20");
297+
});
298+
299+
it("handles empty-string values for named and unnamed operators", () => {
300+
expect(expandExpression("?name", { name: "" })).toBe("?name=");
301+
expect(expandExpression(";name", { name: "" })).toBe(";name");
302+
expect(expandExpression("name", { name: "" })).toBe("");
303+
expect(expandExpression("+name", { name: "" })).toBe("");
304+
});
305+
306+
it("supports dot and ampersand operators", () => {
307+
expect(expandExpression(".ext", { ext: "json" })).toBe(".json");
308+
expect(expandExpression("&page", { page: 2 })).toBe("&page=2");
309+
});
310+
311+
it("rejects invalid varspec syntax", () => {
312+
expect(() =>
313+
expandExpression("user-name", { "user-name": 1 }),
314+
).toThrowError(Error, "Invalid varspec: user-name");
315+
});
4316
});
5317
});

src/services/rest/rfc.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,6 @@ export function expandExpression(
127127

128128
const conf = OP[op];
129129

130-
if (!conf) throw new Error(`Unsupported operator: ${op}`);
131-
132130
// split varspecs by comma, preserve whitespace trimmed
133131
const varspecs = varlist
134132
.split(",")
@@ -254,16 +252,9 @@ export function expandExpression(
254252
.join(",");
255253

256254
if (conf.named) {
257-
if (pairs === "") {
258-
expandedParts.push(
259-
pctEncode(varname, conf.allowReserved) +
260-
(conf.ifEmpty === "=" ? conf.ifEmpty : ""),
261-
);
262-
} else {
263-
expandedParts.push(
264-
`${pctEncode(varname, conf.allowReserved)}=${pairs}`,
265-
);
266-
}
255+
expandedParts.push(
256+
`${pctEncode(varname, conf.allowReserved)}=${pairs}`,
257+
);
267258
} else {
268259
expandedParts.push(pairs);
269260
}

0 commit comments

Comments
 (0)