@localpulse/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/package.json +37 -0
- package/src/index.test.ts +137 -0
- package/src/index.ts +469 -0
- package/src/lib/api-response.ts +63 -0
- package/src/lib/api-url.ts +15 -0
- package/src/lib/argv.ts +126 -0
- package/src/lib/auth.ts +9 -0
- package/src/lib/cli-read-client.test.ts +94 -0
- package/src/lib/cli-read-client.ts +97 -0
- package/src/lib/cli-read-types.ts +27 -0
- package/src/lib/credentials.test.ts +115 -0
- package/src/lib/credentials.ts +113 -0
- package/src/lib/login.test.ts +73 -0
- package/src/lib/login.ts +42 -0
- package/src/lib/output.ts +16 -0
- package/src/lib/research-reader.ts +29 -0
- package/src/lib/research-schema.test.ts +180 -0
- package/src/lib/research-schema.ts +160 -0
- package/src/lib/token.test.ts +17 -0
- package/src/lib/token.ts +8 -0
- package/src/lib/upload-client.test.ts +82 -0
- package/src/lib/upload-client.ts +353 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
generateResearchSkeleton,
|
|
5
|
+
mapResearchToUploadFields,
|
|
6
|
+
stitchResearchContext,
|
|
7
|
+
validateResearchPayload,
|
|
8
|
+
} from "./research-schema";
|
|
9
|
+
|
|
10
|
+
describe("validateResearchPayload", () => {
|
|
11
|
+
it("accepts a full payload with all fields", () => {
|
|
12
|
+
const payload = validateResearchPayload({
|
|
13
|
+
performers: [
|
|
14
|
+
{
|
|
15
|
+
name: "DJ Nobu",
|
|
16
|
+
type: "DJ",
|
|
17
|
+
genre: "techno",
|
|
18
|
+
socials: ["https://instagram.com/djnobu"],
|
|
19
|
+
context: "Berlin-based Japanese DJ",
|
|
20
|
+
},
|
|
21
|
+
{ name: "Objekt", genre: "electro" },
|
|
22
|
+
],
|
|
23
|
+
organizer: {
|
|
24
|
+
name: "Dekmantel",
|
|
25
|
+
socials: ["https://instagram.com/dekmantel"],
|
|
26
|
+
context: "Amsterdam collective, running events since 2007",
|
|
27
|
+
},
|
|
28
|
+
venue: {
|
|
29
|
+
name: "Shelter",
|
|
30
|
+
city: "Amsterdam",
|
|
31
|
+
google_place_id: "ChIJ123",
|
|
32
|
+
context: "Underground club, 350 capacity",
|
|
33
|
+
},
|
|
34
|
+
event: {
|
|
35
|
+
title: "Reaktor Night",
|
|
36
|
+
date: "2026-03-14T22:00",
|
|
37
|
+
type: "club night",
|
|
38
|
+
price: "€15-25",
|
|
39
|
+
urls: ["https://ra.co/events/123"],
|
|
40
|
+
ticket_url: "https://tickets.example.com",
|
|
41
|
+
context: "Part of ADE week",
|
|
42
|
+
},
|
|
43
|
+
context: "Two rooms",
|
|
44
|
+
});
|
|
45
|
+
expect(payload.performers).toHaveLength(2);
|
|
46
|
+
expect(payload.performers![0].type).toBe("DJ");
|
|
47
|
+
expect(payload.venue?.context).toBe("Underground club, 350 capacity");
|
|
48
|
+
expect(payload.event?.type).toBe("club night");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts an empty object", () => {
|
|
52
|
+
expect(validateResearchPayload({})).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("accepts a payload with only performers", () => {
|
|
56
|
+
const payload = validateResearchPayload({
|
|
57
|
+
performers: [{ name: "Objekt" }],
|
|
58
|
+
});
|
|
59
|
+
expect(payload.performers).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects a payload where performers is not an array", () => {
|
|
63
|
+
expect(() => validateResearchPayload({ performers: "DJ Nobu" })).toThrow("Invalid research payload");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects a performer missing name", () => {
|
|
67
|
+
expect(() => validateResearchPayload({ performers: [{ genre: "techno" }] })).toThrow("Invalid research payload");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("stitchResearchContext", () => {
|
|
72
|
+
it("produces formatted output preserving entity context", () => {
|
|
73
|
+
const result = stitchResearchContext({
|
|
74
|
+
performers: [
|
|
75
|
+
{
|
|
76
|
+
name: "DJ Nobu",
|
|
77
|
+
type: "DJ",
|
|
78
|
+
genre: "techno",
|
|
79
|
+
socials: ["https://instagram.com/djnobu"],
|
|
80
|
+
context: "Berlin-based, known for long hypnotic sets",
|
|
81
|
+
},
|
|
82
|
+
{ name: "Objekt" },
|
|
83
|
+
],
|
|
84
|
+
organizer: {
|
|
85
|
+
name: "Dekmantel",
|
|
86
|
+
socials: ["https://instagram.com/dekmantel"],
|
|
87
|
+
context: "Amsterdam collective since 2007",
|
|
88
|
+
},
|
|
89
|
+
venue: {
|
|
90
|
+
name: "Shelter",
|
|
91
|
+
city: "Amsterdam",
|
|
92
|
+
context: "Underground club, 350 capacity, one room",
|
|
93
|
+
},
|
|
94
|
+
event: {
|
|
95
|
+
type: "club night",
|
|
96
|
+
price: "€15-25",
|
|
97
|
+
context: "Part of ADE week. Doors at 22:00.",
|
|
98
|
+
},
|
|
99
|
+
context: "Cash only at door",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result).toContain("## Performers");
|
|
103
|
+
expect(result).toContain("- DJ Nobu (DJ, techno)");
|
|
104
|
+
expect(result).toContain("Berlin-based, known for long hypnotic sets");
|
|
105
|
+
expect(result).toContain("Profiles: https://instagram.com/djnobu");
|
|
106
|
+
expect(result).toContain("- Objekt");
|
|
107
|
+
expect(result).toContain("## Organizer");
|
|
108
|
+
expect(result).toContain("Amsterdam collective since 2007");
|
|
109
|
+
expect(result).toContain("## Venue");
|
|
110
|
+
expect(result).toContain("Underground club, 350 capacity, one room");
|
|
111
|
+
expect(result).toContain("## Event");
|
|
112
|
+
expect(result).toContain("Type: club night");
|
|
113
|
+
expect(result).toContain("Price: €15-25");
|
|
114
|
+
expect(result).toContain("Part of ADE week. Doors at 22:00.");
|
|
115
|
+
expect(result).toContain("## Additional context");
|
|
116
|
+
expect(result).toContain("Cash only at door");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns empty string for empty payload", () => {
|
|
120
|
+
expect(stitchResearchContext({})).toBe("");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("omits sections without data", () => {
|
|
124
|
+
const result = stitchResearchContext({
|
|
125
|
+
performers: [{ name: "Objekt" }],
|
|
126
|
+
});
|
|
127
|
+
expect(result).toContain("## Performers");
|
|
128
|
+
expect(result).not.toContain("## Organizer");
|
|
129
|
+
expect(result).not.toContain("## Venue");
|
|
130
|
+
expect(result).not.toContain("## Event");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("mapResearchToUploadFields", () => {
|
|
135
|
+
it("extracts direct fields", () => {
|
|
136
|
+
const mapped = mapResearchToUploadFields({
|
|
137
|
+
venue: { name: "Shelter", city: "Amsterdam", google_place_id: "ChIJ123" },
|
|
138
|
+
event: {
|
|
139
|
+
title: "Reaktor Night",
|
|
140
|
+
date: "2026-03-14T22:00",
|
|
141
|
+
urls: ["https://ra.co/events/123"],
|
|
142
|
+
ticket_url: "https://tickets.example.com",
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
expect(mapped).toEqual({
|
|
146
|
+
title: "Reaktor Night",
|
|
147
|
+
datetime: "2026-03-14T22:00",
|
|
148
|
+
city: "Amsterdam",
|
|
149
|
+
venue: "Shelter",
|
|
150
|
+
venuePlaceId: "ChIJ123",
|
|
151
|
+
urls: ["https://ra.co/events/123"],
|
|
152
|
+
ticketUrl: "https://tickets.example.com",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefineds for empty payload", () => {
|
|
157
|
+
const mapped = mapResearchToUploadFields({});
|
|
158
|
+
expect(mapped.title).toBeUndefined();
|
|
159
|
+
expect(mapped.city).toBeUndefined();
|
|
160
|
+
expect(mapped.urls).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("generateResearchSkeleton", () => {
|
|
165
|
+
it("produces a payload that passes validation", () => {
|
|
166
|
+
const skeleton = generateResearchSkeleton();
|
|
167
|
+
expect(() => validateResearchPayload(skeleton)).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("includes all entity sections with example data", () => {
|
|
171
|
+
const skeleton = generateResearchSkeleton();
|
|
172
|
+
expect(skeleton.performers![0].name).toBeTruthy();
|
|
173
|
+
expect(skeleton.performers![0].type).toBeTruthy();
|
|
174
|
+
expect(skeleton.performers![0].context).toBeTruthy();
|
|
175
|
+
expect(skeleton.organizer!.context).toBeTruthy();
|
|
176
|
+
expect(skeleton.venue!.context).toBeTruthy();
|
|
177
|
+
expect(skeleton.event!.type).toBeTruthy();
|
|
178
|
+
expect(skeleton.event!.context).toBeTruthy();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
import Ajv from "ajv";
|
|
3
|
+
|
|
4
|
+
const PerformerSchema = Type.Object({
|
|
5
|
+
name: Type.String(),
|
|
6
|
+
type: Type.Optional(Type.String()),
|
|
7
|
+
genre: Type.Optional(Type.String()),
|
|
8
|
+
socials: Type.Optional(Type.Array(Type.String())),
|
|
9
|
+
context: Type.Optional(Type.String()),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const OrganizerSchema = Type.Object({
|
|
13
|
+
name: Type.String(),
|
|
14
|
+
socials: Type.Optional(Type.Array(Type.String())),
|
|
15
|
+
context: Type.Optional(Type.String()),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const VenueSchema = Type.Object({
|
|
19
|
+
name: Type.Optional(Type.String()),
|
|
20
|
+
city: Type.Optional(Type.String()),
|
|
21
|
+
google_place_id: Type.Optional(Type.String()),
|
|
22
|
+
context: Type.Optional(Type.String()),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const EventSchema = Type.Object({
|
|
26
|
+
title: Type.Optional(Type.String()),
|
|
27
|
+
date: Type.Optional(Type.String()),
|
|
28
|
+
type: Type.Optional(Type.String()),
|
|
29
|
+
price: Type.Optional(Type.String()),
|
|
30
|
+
urls: Type.Optional(Type.Array(Type.String())),
|
|
31
|
+
ticket_url: Type.Optional(Type.String()),
|
|
32
|
+
context: Type.Optional(Type.String()),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const ResearchPayloadSchema = Type.Object({
|
|
36
|
+
performers: Type.Optional(Type.Array(PerformerSchema)),
|
|
37
|
+
organizer: Type.Optional(OrganizerSchema),
|
|
38
|
+
venue: Type.Optional(VenueSchema),
|
|
39
|
+
event: Type.Optional(EventSchema),
|
|
40
|
+
context: Type.Optional(Type.String()),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type ResearchPayload = Static<typeof ResearchPayloadSchema>;
|
|
44
|
+
|
|
45
|
+
const ajv = new Ajv({ allErrors: true });
|
|
46
|
+
const validate = ajv.compile(ResearchPayloadSchema);
|
|
47
|
+
|
|
48
|
+
export function validateResearchPayload(data: unknown): ResearchPayload {
|
|
49
|
+
if (validate(data)) {
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
const messages = (validate.errors ?? []).map((e) => `${e.instancePath || "/"}: ${e.message}`);
|
|
53
|
+
throw new Error(`Invalid research payload:\n${messages.join("\n")}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function generateResearchSkeleton(): ResearchPayload {
|
|
57
|
+
return {
|
|
58
|
+
performers: [
|
|
59
|
+
{
|
|
60
|
+
name: "DJ Nobu",
|
|
61
|
+
type: "DJ",
|
|
62
|
+
genre: "techno",
|
|
63
|
+
socials: ["https://instagram.com/djnobu", "https://ra.co/dj/djnobu"],
|
|
64
|
+
context: "Berlin-based Japanese DJ, known for long hypnotic sets",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
organizer: {
|
|
68
|
+
name: "Dekmantel",
|
|
69
|
+
socials: ["https://instagram.com/daboratorium"],
|
|
70
|
+
context: "Amsterdam-based collective, running events since 2007",
|
|
71
|
+
},
|
|
72
|
+
venue: {
|
|
73
|
+
name: "Shelter Amsterdam",
|
|
74
|
+
city: "Amsterdam",
|
|
75
|
+
google_place_id: "",
|
|
76
|
+
context: "Underground club beneath A'DAM Tower, 350 capacity, one room",
|
|
77
|
+
},
|
|
78
|
+
event: {
|
|
79
|
+
title: "Shelter Presents: DJ Nobu",
|
|
80
|
+
date: "2026-03-14T22:00:00+01:00",
|
|
81
|
+
type: "club night",
|
|
82
|
+
price: "€15-25",
|
|
83
|
+
urls: ["https://ra.co/events/1234567"],
|
|
84
|
+
ticket_url: "https://tickets.example.com/shelter-nobu",
|
|
85
|
+
context: "Part of ADE week. Doors at 22:00. Cash only at door.",
|
|
86
|
+
},
|
|
87
|
+
context: "",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mapResearchToUploadFields(
|
|
92
|
+
payload: ResearchPayload,
|
|
93
|
+
): {
|
|
94
|
+
title?: string;
|
|
95
|
+
datetime?: string;
|
|
96
|
+
city?: string;
|
|
97
|
+
venue?: string;
|
|
98
|
+
venuePlaceId?: string;
|
|
99
|
+
urls?: string[];
|
|
100
|
+
ticketUrl?: string;
|
|
101
|
+
} {
|
|
102
|
+
return {
|
|
103
|
+
title: payload.event?.title || undefined,
|
|
104
|
+
datetime: payload.event?.date || undefined,
|
|
105
|
+
city: payload.venue?.city || undefined,
|
|
106
|
+
venue: payload.venue?.name || undefined,
|
|
107
|
+
venuePlaceId: payload.venue?.google_place_id || undefined,
|
|
108
|
+
urls: payload.event?.urls?.length ? payload.event.urls : undefined,
|
|
109
|
+
ticketUrl: payload.event?.ticket_url || undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function stitchResearchContext(payload: ResearchPayload): string {
|
|
114
|
+
const sections: string[] = [];
|
|
115
|
+
|
|
116
|
+
if (payload.performers?.length) {
|
|
117
|
+
const lines = payload.performers.map((p) => {
|
|
118
|
+
const parts = [`- ${p.name}`];
|
|
119
|
+
const attrs: string[] = [];
|
|
120
|
+
if (p.type) attrs.push(p.type);
|
|
121
|
+
if (p.genre) attrs.push(p.genre);
|
|
122
|
+
if (attrs.length) parts[0] += ` (${attrs.join(", ")})`;
|
|
123
|
+
if (p.socials?.length) parts.push(` Profiles: ${p.socials.join(", ")}`);
|
|
124
|
+
if (p.context) parts.push(` ${p.context}`);
|
|
125
|
+
return parts.join("\n");
|
|
126
|
+
});
|
|
127
|
+
sections.push(`## Performers\n${lines.join("\n")}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (payload.organizer?.name) {
|
|
131
|
+
const lines = [`- ${payload.organizer.name}`];
|
|
132
|
+
if (payload.organizer.socials?.length) {
|
|
133
|
+
lines.push(` Profiles: ${payload.organizer.socials.join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
if (payload.organizer.context) {
|
|
136
|
+
lines.push(` ${payload.organizer.context}`);
|
|
137
|
+
}
|
|
138
|
+
sections.push(`## Organizer\n${lines.join("\n")}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (payload.venue?.context) {
|
|
142
|
+
sections.push(`## Venue\n${payload.venue.context}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (payload.event) {
|
|
146
|
+
const eventParts: string[] = [];
|
|
147
|
+
if (payload.event.type) eventParts.push(`Type: ${payload.event.type}`);
|
|
148
|
+
if (payload.event.price) eventParts.push(`Price: ${payload.event.price}`);
|
|
149
|
+
if (payload.event.context) eventParts.push(payload.event.context);
|
|
150
|
+
if (eventParts.length) {
|
|
151
|
+
sections.push(`## Event\n${eventParts.join("\n")}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (payload.context?.trim()) {
|
|
156
|
+
sections.push(`## Additional context\n${payload.context.trim()}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return sections.join("\n\n");
|
|
160
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { isLikelyCliToken } from "./token";
|
|
4
|
+
|
|
5
|
+
describe("token", () => {
|
|
6
|
+
it("accepts current cli token format", () => {
|
|
7
|
+
expect(
|
|
8
|
+
isLikelyCliToken("lp_cli_11111111-1111-4111-8111-111111111111_secret"),
|
|
9
|
+
).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("accepts legacy mcp token format", () => {
|
|
13
|
+
expect(
|
|
14
|
+
isLikelyCliToken("lp_mcp_11111111-1111-4111-8111-111111111111_secret"),
|
|
15
|
+
).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
package/src/lib/token.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { CliApiError, verifyCliToken } from "./upload-client";
|
|
4
|
+
|
|
5
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("upload-client", () => {
|
|
12
|
+
it("calls the token verification endpoint", async () => {
|
|
13
|
+
globalThis.fetch = mock(async (input, init) => {
|
|
14
|
+
expect(String(input)).toBe("https://localpulse.nl/api/v1/token/verify");
|
|
15
|
+
expect(init?.headers).toEqual({
|
|
16
|
+
accept: "application/json",
|
|
17
|
+
authorization: "Bearer lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return new Response(
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
authenticated: true,
|
|
23
|
+
email: "user@example.com",
|
|
24
|
+
}),
|
|
25
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
26
|
+
);
|
|
27
|
+
}) as typeof fetch;
|
|
28
|
+
|
|
29
|
+
const result = await verifyCliToken(
|
|
30
|
+
"https://localpulse.nl",
|
|
31
|
+
"lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
authenticated: true,
|
|
36
|
+
email: "user@example.com",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("surfaces structured HTTP errors", async () => {
|
|
41
|
+
globalThis.fetch = mock(async () => {
|
|
42
|
+
return new Response(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
detail: "Invalid CLI token",
|
|
45
|
+
}),
|
|
46
|
+
{ status: 401, headers: { "Content-Type": "application/json" } },
|
|
47
|
+
);
|
|
48
|
+
}) as typeof fetch;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await verifyCliToken(
|
|
52
|
+
"https://localpulse.nl",
|
|
53
|
+
"lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
54
|
+
);
|
|
55
|
+
throw new Error("expected verifyCliToken to throw");
|
|
56
|
+
} catch (error) {
|
|
57
|
+
expect(error).toBeInstanceOf(CliApiError);
|
|
58
|
+
expect((error as CliApiError).httpStatus).toBe(401);
|
|
59
|
+
expect((error as CliApiError).message).toBe("Invalid CLI token");
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("fails fast on non-json API error responses", async () => {
|
|
64
|
+
globalThis.fetch = mock(async () => {
|
|
65
|
+
return new Response("<html>bad gateway</html>", {
|
|
66
|
+
status: 502,
|
|
67
|
+
headers: { "Content-Type": "text/html" },
|
|
68
|
+
});
|
|
69
|
+
}) as typeof fetch;
|
|
70
|
+
|
|
71
|
+
await expect(
|
|
72
|
+
verifyCliToken(
|
|
73
|
+
"https://localpulse.nl",
|
|
74
|
+
"lp_cli_11111111-1111-4111-8111-111111111111_secret",
|
|
75
|
+
),
|
|
76
|
+
).rejects.toMatchObject({
|
|
77
|
+
message: "API returned a non-JSON error response",
|
|
78
|
+
httpStatus: 502,
|
|
79
|
+
body: { bodySnippet: "<html>bad gateway</html>" },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|