@open-loyalty/mcp-server 1.0.0 → 1.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 +134 -12
- package/dist/auth/provider.d.ts +33 -0
- package/dist/auth/provider.js +395 -0
- package/dist/auth/storage.d.ts +16 -0
- package/dist/auth/storage.js +98 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +22 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +214 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/package.json +11 -10
- package/dist/tools/member.test.d.ts +0 -1
- package/dist/tools/member.test.js +0 -213
- package/dist/tools/points.test.d.ts +0 -1
- package/dist/tools/points.test.js +0 -292
- package/dist/tools/reward.test.d.ts +0 -1
- package/dist/tools/reward.test.js +0 -240
- package/dist/tools/transaction.test.d.ts +0 -1
- package/dist/tools/transaction.test.js +0 -235
- package/dist/utils/cursor.d.ts +0 -84
- package/dist/utils/cursor.js +0 -117
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage abstraction for OAuth data
|
|
3
|
+
* Uses Redis if REDIS_URL is set, otherwise falls back to in-memory storage
|
|
4
|
+
*/
|
|
5
|
+
import { Redis } from "ioredis";
|
|
6
|
+
/**
|
|
7
|
+
* In-memory storage for local development
|
|
8
|
+
*/
|
|
9
|
+
class InMemoryStorage {
|
|
10
|
+
data = new Map();
|
|
11
|
+
async get(key) {
|
|
12
|
+
const entry = this.data.get(key);
|
|
13
|
+
if (!entry)
|
|
14
|
+
return null;
|
|
15
|
+
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
|
16
|
+
this.data.delete(key);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return entry.value;
|
|
20
|
+
}
|
|
21
|
+
async set(key, value, ttlMs) {
|
|
22
|
+
this.data.set(key, {
|
|
23
|
+
value,
|
|
24
|
+
expiresAt: ttlMs ? Date.now() + ttlMs : undefined,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async delete(key) {
|
|
28
|
+
this.data.delete(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Redis storage for production
|
|
33
|
+
*/
|
|
34
|
+
class RedisStorage {
|
|
35
|
+
client;
|
|
36
|
+
constructor(redisUrl) {
|
|
37
|
+
this.client = new Redis(redisUrl, {
|
|
38
|
+
maxRetriesPerRequest: 3,
|
|
39
|
+
retryStrategy: (times) => Math.min(times * 100, 3000),
|
|
40
|
+
});
|
|
41
|
+
this.client.on("error", (err) => {
|
|
42
|
+
console.error("Redis connection error:", err.message);
|
|
43
|
+
});
|
|
44
|
+
this.client.on("connect", () => {
|
|
45
|
+
console.log("Connected to Redis");
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async get(key) {
|
|
49
|
+
const data = await this.client.get(key);
|
|
50
|
+
if (!data)
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(data);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async set(key, value, ttlMs) {
|
|
60
|
+
const data = JSON.stringify(value);
|
|
61
|
+
if (ttlMs) {
|
|
62
|
+
await this.client.set(key, data, "PX", ttlMs);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await this.client.set(key, data);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async delete(key) {
|
|
69
|
+
await this.client.del(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Singleton storage instance
|
|
73
|
+
let storage = null;
|
|
74
|
+
/**
|
|
75
|
+
* Get the storage backend (Redis if available, otherwise in-memory)
|
|
76
|
+
*/
|
|
77
|
+
export function getStorage() {
|
|
78
|
+
if (storage)
|
|
79
|
+
return storage;
|
|
80
|
+
const redisUrl = process.env.REDIS_URL;
|
|
81
|
+
if (redisUrl) {
|
|
82
|
+
console.log("Using Redis for OAuth storage");
|
|
83
|
+
storage = new RedisStorage(redisUrl);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.log("Using in-memory storage for OAuth (set REDIS_URL for persistence)");
|
|
87
|
+
storage = new InMemoryStorage();
|
|
88
|
+
}
|
|
89
|
+
return storage;
|
|
90
|
+
}
|
|
91
|
+
// Storage key prefixes
|
|
92
|
+
export const KEYS = {
|
|
93
|
+
client: (id) => `oauth:client:${id}`,
|
|
94
|
+
authCode: (code) => `oauth:code:${code}`,
|
|
95
|
+
session: (id) => `oauth:session:${id}`,
|
|
96
|
+
token: (token) => `oauth:token:${token}`,
|
|
97
|
+
config: (clientId) => `oauth:config:${clientId}`,
|
|
98
|
+
};
|
package/dist/config.d.ts
CHANGED
|
@@ -13,5 +13,17 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
13
13
|
defaultStoreCode: string;
|
|
14
14
|
}>;
|
|
15
15
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
16
|
+
/**
|
|
17
|
+
* Sets a config override for the current request (OAuth mode)
|
|
18
|
+
*/
|
|
19
|
+
export declare function setConfigOverride(override: {
|
|
20
|
+
apiUrl: string;
|
|
21
|
+
apiToken: string;
|
|
22
|
+
storeCode: string;
|
|
23
|
+
}): void;
|
|
24
|
+
/**
|
|
25
|
+
* Clears the config override after request completes
|
|
26
|
+
*/
|
|
27
|
+
export declare function clearConfigOverride(): void;
|
|
16
28
|
export declare function getConfig(): Config;
|
|
17
29
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -5,7 +5,29 @@ const ConfigSchema = z.object({
|
|
|
5
5
|
defaultStoreCode: z.string().min(1),
|
|
6
6
|
});
|
|
7
7
|
let config = null;
|
|
8
|
+
// Per-request config override for OAuth mode
|
|
9
|
+
let configOverride = null;
|
|
10
|
+
/**
|
|
11
|
+
* Sets a config override for the current request (OAuth mode)
|
|
12
|
+
*/
|
|
13
|
+
export function setConfigOverride(override) {
|
|
14
|
+
configOverride = {
|
|
15
|
+
apiUrl: override.apiUrl,
|
|
16
|
+
apiToken: override.apiToken,
|
|
17
|
+
defaultStoreCode: override.storeCode,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Clears the config override after request completes
|
|
22
|
+
*/
|
|
23
|
+
export function clearConfigOverride() {
|
|
24
|
+
configOverride = null;
|
|
25
|
+
}
|
|
8
26
|
export function getConfig() {
|
|
27
|
+
// Return override if set (OAuth mode)
|
|
28
|
+
if (configOverride) {
|
|
29
|
+
return configOverride;
|
|
30
|
+
}
|
|
9
31
|
if (config) {
|
|
10
32
|
return config;
|
|
11
33
|
}
|
package/dist/http.d.ts
ADDED
package/dist/http.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import cors from "cors";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
+
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
8
|
+
import { createServer, SERVER_INSTRUCTIONS } from "./server.js";
|
|
9
|
+
import { getConfig, setConfigOverride, clearConfigOverride } from "./config.js";
|
|
10
|
+
import { createOAuthProvider, completeAuthorization, validateOpenLoyaltyCredentials, getClientConfig, } from "./auth/provider.js";
|
|
11
|
+
// Check if OAuth mode is enabled
|
|
12
|
+
const OAUTH_ENABLED = process.env.OAUTH_ENABLED === "true";
|
|
13
|
+
const BASE_URL = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
|
14
|
+
// In non-OAuth mode, validate config on startup
|
|
15
|
+
if (!OAUTH_ENABLED) {
|
|
16
|
+
try {
|
|
17
|
+
getConfig();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
console.error("Configuration error:", error instanceof Error ? error.message : error);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const app = express();
|
|
25
|
+
// CORS for ChatGPT
|
|
26
|
+
app.use(cors({
|
|
27
|
+
origin: "*",
|
|
28
|
+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
29
|
+
allowedHeaders: ["Content-Type", "Authorization", "MCP-Session-Id", "MCP-Protocol-Version"],
|
|
30
|
+
exposedHeaders: ["MCP-Session-Id"],
|
|
31
|
+
}));
|
|
32
|
+
app.use(express.json());
|
|
33
|
+
// Store transports by session ID for stateful connections
|
|
34
|
+
const transports = new Map();
|
|
35
|
+
// Health check endpoint
|
|
36
|
+
app.get("/health", (_req, res) => {
|
|
37
|
+
res.json({ status: "ok", server: "openloyalty-mcp", oauth: OAUTH_ENABLED });
|
|
38
|
+
});
|
|
39
|
+
// OAuth mode setup
|
|
40
|
+
if (OAUTH_ENABLED) {
|
|
41
|
+
const provider = createOAuthProvider(BASE_URL);
|
|
42
|
+
// Add MCP SDK auth router (handles /.well-known/*, /authorize, /token, /register)
|
|
43
|
+
app.use(mcpAuthRouter({
|
|
44
|
+
provider,
|
|
45
|
+
issuerUrl: new URL(BASE_URL),
|
|
46
|
+
baseUrl: new URL(BASE_URL),
|
|
47
|
+
serviceDocumentationUrl: new URL("https://github.com/OpenLoyalty/openloyalty-mcp"),
|
|
48
|
+
}));
|
|
49
|
+
// Authorization form submission endpoint
|
|
50
|
+
app.post("/authorize/submit", async (req, res) => {
|
|
51
|
+
const { session_id, api_url, api_token, store_code } = req.body;
|
|
52
|
+
if (!session_id || !api_url || !api_token || !store_code) {
|
|
53
|
+
res.status(400).json({ error: "Missing required fields" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const config = {
|
|
57
|
+
apiUrl: api_url.replace(/\/$/, ""),
|
|
58
|
+
apiToken: api_token,
|
|
59
|
+
storeCode: store_code,
|
|
60
|
+
};
|
|
61
|
+
// Validate credentials
|
|
62
|
+
const validation = await validateOpenLoyaltyCredentials(config);
|
|
63
|
+
if (!validation.valid) {
|
|
64
|
+
res.status(400).json({ error: validation.error });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Complete authorization
|
|
68
|
+
const result = await completeAuthorization(session_id, config);
|
|
69
|
+
if ("error" in result) {
|
|
70
|
+
res.status(400).json({ error: result.error });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.json({ redirect_url: result.redirectUrl });
|
|
74
|
+
});
|
|
75
|
+
// Auth middleware for /mcp endpoint
|
|
76
|
+
const authMiddleware = async (req, res, next) => {
|
|
77
|
+
const authHeader = req.headers.authorization;
|
|
78
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
79
|
+
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const token = authHeader.slice(7);
|
|
83
|
+
try {
|
|
84
|
+
const authInfo = await provider.verifyAccessToken(token);
|
|
85
|
+
// Get client's Open Loyalty config
|
|
86
|
+
const config = await getClientConfig(authInfo.clientId);
|
|
87
|
+
if (!config) {
|
|
88
|
+
res.status(401).json({ error: "Open Loyalty not configured. Please re-authorize." });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Set config override for this request
|
|
92
|
+
setConfigOverride(config);
|
|
93
|
+
// Store clientId for cleanup
|
|
94
|
+
req.clientId = authInfo.clientId;
|
|
95
|
+
next();
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
res.status(401).json({
|
|
99
|
+
error: error instanceof Error ? error.message : "Authentication failed",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// Apply auth middleware to /mcp
|
|
104
|
+
app.use("/mcp", authMiddleware);
|
|
105
|
+
}
|
|
106
|
+
// MCP endpoint - handles both initialization and messages
|
|
107
|
+
app.all("/mcp", async (req, res) => {
|
|
108
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
109
|
+
try {
|
|
110
|
+
// Handle GET requests for SSE streams
|
|
111
|
+
if (req.method === "GET") {
|
|
112
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
113
|
+
res.status(400).json({ error: "Invalid or missing session ID for SSE stream" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const transport = transports.get(sessionId);
|
|
117
|
+
await transport.handleRequest(req, res);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Handle DELETE requests for session cleanup
|
|
121
|
+
if (req.method === "DELETE") {
|
|
122
|
+
if (sessionId && transports.has(sessionId)) {
|
|
123
|
+
const transport = transports.get(sessionId);
|
|
124
|
+
await transport.close();
|
|
125
|
+
transports.delete(sessionId);
|
|
126
|
+
res.status(204).send();
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
res.status(404).json({ error: "Session not found" });
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Handle POST requests
|
|
134
|
+
if (req.method === "POST") {
|
|
135
|
+
// Check if this is an initialization request (no session ID)
|
|
136
|
+
if (!sessionId) {
|
|
137
|
+
// Create new session
|
|
138
|
+
const newSessionId = randomUUID();
|
|
139
|
+
const transport = new StreamableHTTPServerTransport({
|
|
140
|
+
sessionIdGenerator: () => newSessionId,
|
|
141
|
+
});
|
|
142
|
+
// Create and connect server
|
|
143
|
+
const server = createServer();
|
|
144
|
+
await server.connect(transport);
|
|
145
|
+
// Store transport for future requests
|
|
146
|
+
transports.set(newSessionId, transport);
|
|
147
|
+
// Clean up on close
|
|
148
|
+
transport.onclose = () => {
|
|
149
|
+
transports.delete(newSessionId);
|
|
150
|
+
};
|
|
151
|
+
// Handle the request
|
|
152
|
+
await transport.handleRequest(req, res, req.body);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Existing session - route to stored transport
|
|
156
|
+
const transport = transports.get(sessionId);
|
|
157
|
+
if (!transport) {
|
|
158
|
+
res.status(404).json({ error: "Session not found. Initialize a new session first." });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await transport.handleRequest(req, res, req.body);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Unsupported method
|
|
165
|
+
res.status(405).json({ error: "Method not allowed" });
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
// Clean up config override in OAuth mode
|
|
169
|
+
if (OAUTH_ENABLED) {
|
|
170
|
+
clearConfigOverride();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// Server info endpoint
|
|
175
|
+
app.get("/", (_req, res) => {
|
|
176
|
+
const endpoints = {
|
|
177
|
+
mcp: "/mcp",
|
|
178
|
+
health: "/health",
|
|
179
|
+
};
|
|
180
|
+
if (OAUTH_ENABLED) {
|
|
181
|
+
endpoints.authorize = "/authorize";
|
|
182
|
+
endpoints.token = "/token";
|
|
183
|
+
endpoints.register = "/register";
|
|
184
|
+
endpoints.oauth_metadata = "/.well-known/oauth-authorization-server";
|
|
185
|
+
}
|
|
186
|
+
res.json({
|
|
187
|
+
name: "Open Loyalty MCP Server",
|
|
188
|
+
version: "1.0.0",
|
|
189
|
+
transport: "streamable-http",
|
|
190
|
+
oauth: OAUTH_ENABLED,
|
|
191
|
+
endpoints,
|
|
192
|
+
instructions: SERVER_INSTRUCTIONS.slice(0, 500) + "...",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
const PORT = parseInt(process.env.MCP_HTTP_PORT || process.env.PORT || "3000", 10);
|
|
196
|
+
app.listen(PORT, () => {
|
|
197
|
+
console.log(`Open Loyalty MCP HTTP Server running on port ${PORT}`);
|
|
198
|
+
console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`);
|
|
199
|
+
console.log(` - Health check: http://localhost:${PORT}/health`);
|
|
200
|
+
console.log(` - Server info: http://localhost:${PORT}/`);
|
|
201
|
+
if (OAUTH_ENABLED) {
|
|
202
|
+
console.log("");
|
|
203
|
+
console.log("OAuth 2.1 enabled:");
|
|
204
|
+
console.log(` - Authorize: ${BASE_URL}/authorize`);
|
|
205
|
+
console.log(` - Token: ${BASE_URL}/token`);
|
|
206
|
+
console.log(` - Register: ${BASE_URL}/register`);
|
|
207
|
+
console.log(` - Metadata: ${BASE_URL}/.well-known/oauth-authorization-server`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.log("");
|
|
211
|
+
console.log("OAuth disabled. Using environment variables for API credentials.");
|
|
212
|
+
console.log("Set OAUTH_ENABLED=true and BASE_URL for OAuth mode.");
|
|
213
|
+
}
|
|
214
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import "dotenv/config";
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-loyalty/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server for Open Loyalty API - enables AI agents to manage loyalty programs, members, points, rewards, and transactions",
|
|
6
6
|
"author": "Marcin Dyguda <md@openloyalty.io>",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/openloyalty/mcp-server.git"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://github.com/openloyalty/mcp-server#readme",
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/openloyalty/mcp-server/issues"
|
|
15
|
-
},
|
|
16
8
|
"keywords": [
|
|
17
9
|
"mcp",
|
|
18
10
|
"model-context-protocol",
|
|
@@ -25,7 +17,8 @@
|
|
|
25
17
|
],
|
|
26
18
|
"main": "dist/index.js",
|
|
27
19
|
"bin": {
|
|
28
|
-
"openloyalty-mcp": "./dist/index.js"
|
|
20
|
+
"openloyalty-mcp": "./dist/index.js",
|
|
21
|
+
"openloyalty-mcp-http": "./dist/http.js"
|
|
29
22
|
},
|
|
30
23
|
"files": [
|
|
31
24
|
"dist",
|
|
@@ -36,7 +29,9 @@
|
|
|
36
29
|
"build": "tsc",
|
|
37
30
|
"prepublishOnly": "npm run build",
|
|
38
31
|
"start": "node dist/index.js",
|
|
32
|
+
"start:http": "node dist/http.js",
|
|
39
33
|
"dev": "tsx src/index.ts",
|
|
34
|
+
"dev:http": "tsx src/http.ts",
|
|
40
35
|
"typecheck": "tsc --noEmit",
|
|
41
36
|
"test": "vitest",
|
|
42
37
|
"test:run": "vitest run",
|
|
@@ -46,9 +41,15 @@
|
|
|
46
41
|
"dependencies": {
|
|
47
42
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
43
|
"axios": "^1.6.0",
|
|
44
|
+
"cors": "^2.8.5",
|
|
45
|
+
"dotenv": "^17.2.3",
|
|
46
|
+
"express": "^5.2.1",
|
|
47
|
+
"ioredis": "^5.9.2",
|
|
49
48
|
"zod": "^3.22.0"
|
|
50
49
|
},
|
|
51
50
|
"devDependencies": {
|
|
51
|
+
"@types/cors": "^2.8.19",
|
|
52
|
+
"@types/express": "^5.0.6",
|
|
52
53
|
"@types/node": "^20.10.0",
|
|
53
54
|
"@vitest/coverage-v8": "^4.0.17",
|
|
54
55
|
"axios-mock-adapter": "^2.1.0",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
// src/tools/member.test.ts
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import { setupMockAxios, teardownMockAxios, getMockAxios } from '../../tests/mocks/http.mock.js';
|
|
4
|
-
import { memberFixtures } from '../../tests/fixtures/member.fixtures.js';
|
|
5
|
-
import { memberCreate, memberGet, memberList, memberUpdate, memberActivate, memberDeactivate, memberDelete, memberGetTierProgress, memberAssignTier, memberRemoveManualTier, } from './member.js';
|
|
6
|
-
describe('Member Operations', () => {
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
setupMockAxios();
|
|
9
|
-
});
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
teardownMockAxios();
|
|
12
|
-
});
|
|
13
|
-
describe('memberCreate', () => {
|
|
14
|
-
it('should create member with required fields only', async () => {
|
|
15
|
-
const mockAxios = getMockAxios();
|
|
16
|
-
mockAxios.onPost('/default/member').reply(200, memberFixtures.createResponse);
|
|
17
|
-
const result = await memberCreate({
|
|
18
|
-
email: 'test@example.com',
|
|
19
|
-
});
|
|
20
|
-
expect(result).toEqual({
|
|
21
|
-
memberId: '550e8400-e29b-41d4-a716-446655440000',
|
|
22
|
-
loyaltyCardNumber: 'CARD123456789',
|
|
23
|
-
email: 'test@example.com',
|
|
24
|
-
});
|
|
25
|
-
// Verify request body format with customer wrapper
|
|
26
|
-
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
27
|
-
expect(requestData).toEqual({
|
|
28
|
-
customer: {
|
|
29
|
-
email: 'test@example.com',
|
|
30
|
-
},
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
it('should create member with all optional fields', async () => {
|
|
34
|
-
const mockAxios = getMockAxios();
|
|
35
|
-
mockAxios.onPost('/default/member').reply(200, {
|
|
36
|
-
customerId: 'full-uuid',
|
|
37
|
-
loyaltyCardNumber: 'CUSTOM123',
|
|
38
|
-
email: 'full@example.com',
|
|
39
|
-
});
|
|
40
|
-
const result = await memberCreate({
|
|
41
|
-
email: 'full@example.com',
|
|
42
|
-
firstName: 'John',
|
|
43
|
-
lastName: 'Doe',
|
|
44
|
-
phone: '+1234567890',
|
|
45
|
-
birthDate: '1990-01-15',
|
|
46
|
-
gender: 'male',
|
|
47
|
-
loyaltyCardNumber: 'CUSTOM123',
|
|
48
|
-
agreement1: true,
|
|
49
|
-
agreement2: false,
|
|
50
|
-
agreement3: true,
|
|
51
|
-
address: {
|
|
52
|
-
street: '123 Main St',
|
|
53
|
-
city: 'New York',
|
|
54
|
-
postal: '10001',
|
|
55
|
-
country: 'US',
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
expect(result.memberId).toBe('full-uuid');
|
|
59
|
-
// Verify all fields in request
|
|
60
|
-
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
61
|
-
expect(requestData.customer.firstName).toBe('John');
|
|
62
|
-
expect(requestData.customer.lastName).toBe('Doe');
|
|
63
|
-
expect(requestData.customer.address.city).toBe('New York');
|
|
64
|
-
expect(requestData.customer.agreement1).toBe(true);
|
|
65
|
-
expect(requestData.customer.agreement2).toBe(false);
|
|
66
|
-
});
|
|
67
|
-
it('should use custom storeCode when provided', async () => {
|
|
68
|
-
const mockAxios = getMockAxios();
|
|
69
|
-
mockAxios.onPost('/custom-store/member').reply(200, {
|
|
70
|
-
customerId: 'uuid',
|
|
71
|
-
email: 'test@example.com',
|
|
72
|
-
});
|
|
73
|
-
await memberCreate({
|
|
74
|
-
storeCode: 'custom-store',
|
|
75
|
-
email: 'test@example.com',
|
|
76
|
-
});
|
|
77
|
-
expect(mockAxios.history.post[0].url).toBe('/custom-store/member');
|
|
78
|
-
});
|
|
79
|
-
it('should throw formatted error on API error', async () => {
|
|
80
|
-
const mockAxios = getMockAxios();
|
|
81
|
-
mockAxios.onPost('/default/member').reply(400, {
|
|
82
|
-
message: 'Email already exists',
|
|
83
|
-
code: 400,
|
|
84
|
-
});
|
|
85
|
-
await expect(memberCreate({ email: 'duplicate@example.com' }))
|
|
86
|
-
.rejects.toThrow();
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
describe('memberGet', () => {
|
|
90
|
-
it('should get member details and map customerId to memberId', async () => {
|
|
91
|
-
const mockAxios = getMockAxios();
|
|
92
|
-
mockAxios.onGet('/default/member/member-uuid').reply(200, memberFixtures.getResponse);
|
|
93
|
-
const result = await memberGet({ memberId: 'member-uuid' });
|
|
94
|
-
expect(result.memberId).toBe('550e8400-e29b-41d4-a716-446655440000');
|
|
95
|
-
expect(result.email).toBe('test@example.com');
|
|
96
|
-
expect(result.firstName).toBe('John');
|
|
97
|
-
expect(result.levelName).toBe('Silver');
|
|
98
|
-
});
|
|
99
|
-
it('should use custom storeCode', async () => {
|
|
100
|
-
const mockAxios = getMockAxios();
|
|
101
|
-
mockAxios.onGet('/other-store/member/id123').reply(200, memberFixtures.getResponse);
|
|
102
|
-
await memberGet({ storeCode: 'other-store', memberId: 'id123' });
|
|
103
|
-
expect(mockAxios.history.get[0].url).toBe('/other-store/member/id123');
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
describe('memberList', () => {
|
|
107
|
-
it('should list members and map customerId to memberId', async () => {
|
|
108
|
-
const mockAxios = getMockAxios();
|
|
109
|
-
mockAxios.onGet('/default/member').reply(200, memberFixtures.listResponse);
|
|
110
|
-
const result = await memberList({});
|
|
111
|
-
expect(result.members).toHaveLength(2);
|
|
112
|
-
expect(result.members[0].memberId).toBe('uuid-1');
|
|
113
|
-
expect(result.members[0].email).toBe('user1@example.com');
|
|
114
|
-
expect(result.total.all).toBe(100);
|
|
115
|
-
expect(result.total.filtered).toBe(2);
|
|
116
|
-
});
|
|
117
|
-
it('should include pagination params with correct names', async () => {
|
|
118
|
-
const mockAxios = getMockAxios();
|
|
119
|
-
mockAxios.onGet(/\/default\/member/).reply(200, memberFixtures.listResponse);
|
|
120
|
-
await memberList({ page: 2, perPage: 25 });
|
|
121
|
-
const url = mockAxios.history.get[0].url;
|
|
122
|
-
expect(url).toContain('_page=2');
|
|
123
|
-
expect(url).toContain('_itemsOnPage=25');
|
|
124
|
-
});
|
|
125
|
-
it('should include filter params', async () => {
|
|
126
|
-
const mockAxios = getMockAxios();
|
|
127
|
-
mockAxios.onGet(/\/default\/member/).reply(200, memberFixtures.listResponse);
|
|
128
|
-
await memberList({
|
|
129
|
-
email: 'test@example.com',
|
|
130
|
-
firstName: 'John',
|
|
131
|
-
active: true,
|
|
132
|
-
});
|
|
133
|
-
const url = mockAxios.history.get[0].url;
|
|
134
|
-
expect(url).toContain('email=test%40example.com');
|
|
135
|
-
expect(url).toContain('firstName=John');
|
|
136
|
-
expect(url).toContain('active=true');
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
describe('memberUpdate', () => {
|
|
140
|
-
it('should update member with customer wrapper', async () => {
|
|
141
|
-
const mockAxios = getMockAxios();
|
|
142
|
-
mockAxios.onPut('/default/member/member-uuid').reply(200);
|
|
143
|
-
await memberUpdate({
|
|
144
|
-
memberId: 'member-uuid',
|
|
145
|
-
firstName: 'Jane',
|
|
146
|
-
lastName: 'Smith',
|
|
147
|
-
});
|
|
148
|
-
const requestData = JSON.parse(mockAxios.history.put[0].data);
|
|
149
|
-
expect(requestData).toEqual({
|
|
150
|
-
customer: {
|
|
151
|
-
firstName: 'Jane',
|
|
152
|
-
lastName: 'Smith',
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
describe('memberActivate', () => {
|
|
158
|
-
it('should call activate endpoint', async () => {
|
|
159
|
-
const mockAxios = getMockAxios();
|
|
160
|
-
mockAxios.onPost('/default/member/member-uuid/activate').reply(200);
|
|
161
|
-
await memberActivate({ memberId: 'member-uuid' });
|
|
162
|
-
expect(mockAxios.history.post[0].url).toBe('/default/member/member-uuid/activate');
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
describe('memberDeactivate', () => {
|
|
166
|
-
it('should call deactivate endpoint', async () => {
|
|
167
|
-
const mockAxios = getMockAxios();
|
|
168
|
-
mockAxios.onPost('/default/member/member-uuid/deactivate').reply(200);
|
|
169
|
-
await memberDeactivate({ memberId: 'member-uuid' });
|
|
170
|
-
expect(mockAxios.history.post[0].url).toBe('/default/member/member-uuid/deactivate');
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
describe('memberDelete', () => {
|
|
174
|
-
it('should call delete endpoint', async () => {
|
|
175
|
-
const mockAxios = getMockAxios();
|
|
176
|
-
mockAxios.onDelete('/default/member/member-uuid').reply(200);
|
|
177
|
-
await memberDelete({ memberId: 'member-uuid' });
|
|
178
|
-
expect(mockAxios.history.delete[0].url).toBe('/default/member/member-uuid');
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
describe('memberGetTierProgress', () => {
|
|
182
|
-
it('should get tier progress information', async () => {
|
|
183
|
-
const mockAxios = getMockAxios();
|
|
184
|
-
mockAxios.onGet('/default/member/member-uuid/tier').reply(200, memberFixtures.tierProgressResponse);
|
|
185
|
-
const result = await memberGetTierProgress({ memberId: 'member-uuid' });
|
|
186
|
-
expect(result.currentTier?.name).toBe('Silver');
|
|
187
|
-
expect(result.nextTier?.name).toBe('Gold');
|
|
188
|
-
expect(result.currentValue).toBe(750);
|
|
189
|
-
expect(result.requiredValue).toBe(1000);
|
|
190
|
-
expect(result.progressPercent).toBe(75);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
describe('memberAssignTier', () => {
|
|
194
|
-
it('should assign tier to member', async () => {
|
|
195
|
-
const mockAxios = getMockAxios();
|
|
196
|
-
mockAxios.onPost('/default/member/member-uuid/tier').reply(200);
|
|
197
|
-
await memberAssignTier({
|
|
198
|
-
memberId: 'member-uuid',
|
|
199
|
-
levelId: 'gold-level-uuid',
|
|
200
|
-
});
|
|
201
|
-
const requestData = JSON.parse(mockAxios.history.post[0].data);
|
|
202
|
-
expect(requestData.levelId).toBe('gold-level-uuid');
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
describe('memberRemoveManualTier', () => {
|
|
206
|
-
it('should remove manual tier assignment', async () => {
|
|
207
|
-
const mockAxios = getMockAxios();
|
|
208
|
-
mockAxios.onDelete('/default/member/member-uuid/tier').reply(200);
|
|
209
|
-
await memberRemoveManualTier({ memberId: 'member-uuid' });
|
|
210
|
-
expect(mockAxios.history.delete[0].url).toBe('/default/member/member-uuid/tier');
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|