@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.
@@ -1,235 +0,0 @@
1
- // src/tools/transaction.test.ts
2
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
- import { setupMockAxios, teardownMockAxios, getMockAxios } from '../../tests/mocks/http.mock.js';
4
- import { transactionFixtures } from '../../tests/fixtures/transaction.fixtures.js';
5
- import { transactionCreate, transactionGet, transactionList, transactionAssignMember, } from './transaction.js';
6
- describe('Transaction Operations', () => {
7
- beforeEach(() => {
8
- setupMockAxios();
9
- });
10
- afterEach(() => {
11
- teardownMockAxios();
12
- });
13
- describe('transactionCreate', () => {
14
- it('should create transaction with transaction wrapper', async () => {
15
- const mockAxios = getMockAxios();
16
- mockAxios.onPost('/default/transaction').reply(200, transactionFixtures.createResponse);
17
- const result = await transactionCreate({
18
- header: {
19
- documentNumber: 'DOC-001',
20
- purchasedAt: '2026-01-15T10:00:00Z',
21
- },
22
- items: [
23
- {
24
- sku: 'PROD-001',
25
- name: 'Test Product',
26
- grossValue: 100,
27
- category: 'Electronics',
28
- },
29
- ],
30
- });
31
- expect(result.transactionId).toBe('txn-uuid-123');
32
- // Verify request body format with transaction wrapper
33
- const requestData = JSON.parse(mockAxios.history.post[0].data);
34
- expect(requestData.transaction).toBeDefined();
35
- expect(requestData.transaction.header.documentNumber).toBe('DOC-001');
36
- expect(requestData.transaction.header.documentType).toBe('sell');
37
- expect(requestData.transaction.items).toHaveLength(1);
38
- expect(requestData.transaction.items[0].sku).toBe('PROD-001');
39
- });
40
- it('should include customerData for member matching', async () => {
41
- const mockAxios = getMockAxios();
42
- mockAxios.onPost('/default/transaction').reply(200, {
43
- transactionId: 'txn-uuid',
44
- pointsEarned: 100,
45
- });
46
- const result = await transactionCreate({
47
- header: {
48
- documentNumber: 'DOC-002',
49
- purchasedAt: '2026-01-15T10:00:00Z',
50
- },
51
- items: [
52
- {
53
- sku: 'PROD-001',
54
- name: 'Product',
55
- grossValue: 100,
56
- category: 'Category',
57
- },
58
- ],
59
- customerData: {
60
- email: 'customer@example.com',
61
- },
62
- });
63
- expect(result.pointsEarned).toBe(100);
64
- const requestData = JSON.parse(mockAxios.history.post[0].data);
65
- expect(requestData.transaction.customerData.email).toBe('customer@example.com');
66
- });
67
- it('should handle return transactions', async () => {
68
- const mockAxios = getMockAxios();
69
- mockAxios.onPost('/default/transaction').reply(200, transactionFixtures.createResponse);
70
- await transactionCreate({
71
- header: {
72
- documentNumber: 'RETURN-001',
73
- purchasedAt: '2026-01-15T10:00:00Z',
74
- documentType: 'return',
75
- linkedDocumentNumber: 'DOC-001',
76
- },
77
- items: [
78
- {
79
- sku: 'PROD-001',
80
- name: 'Product',
81
- grossValue: -50,
82
- category: 'Category',
83
- },
84
- ],
85
- });
86
- const requestData = JSON.parse(mockAxios.history.post[0].data);
87
- expect(requestData.transaction.header.documentType).toBe('return');
88
- expect(requestData.transaction.header.linkedDocumentNumber).toBe('DOC-001');
89
- });
90
- it('should use highPrecisionQuantity', async () => {
91
- const mockAxios = getMockAxios();
92
- mockAxios.onPost('/default/transaction').reply(200, transactionFixtures.createResponse);
93
- await transactionCreate({
94
- header: {
95
- documentNumber: 'DOC-003',
96
- purchasedAt: '2026-01-15T10:00:00Z',
97
- },
98
- items: [
99
- {
100
- sku: 'PROD-001',
101
- name: 'Product',
102
- grossValue: 100,
103
- category: 'Category',
104
- highPrecisionQuantity: 1.5,
105
- },
106
- ],
107
- });
108
- const requestData = JSON.parse(mockAxios.history.post[0].data);
109
- expect(requestData.transaction.items[0].highPrecisionQuantity).toBe(1.5);
110
- });
111
- it('should use custom storeCode', async () => {
112
- const mockAxios = getMockAxios();
113
- mockAxios.onPost('/custom-store/transaction').reply(200, transactionFixtures.createResponse);
114
- await transactionCreate({
115
- storeCode: 'custom-store',
116
- header: {
117
- documentNumber: 'DOC-001',
118
- purchasedAt: '2026-01-15T10:00:00Z',
119
- },
120
- items: [
121
- {
122
- sku: 'PROD-001',
123
- name: 'Product',
124
- grossValue: 100,
125
- category: 'Category',
126
- },
127
- ],
128
- });
129
- expect(mockAxios.history.post[0].url).toBe('/custom-store/transaction');
130
- });
131
- });
132
- describe('transactionGet', () => {
133
- it('should get transaction details', async () => {
134
- const mockAxios = getMockAxios();
135
- mockAxios.onGet('/default/transaction/txn-uuid').reply(200, transactionFixtures.getResponse);
136
- const result = await transactionGet({ transactionId: 'txn-uuid' });
137
- expect(result.transactionId).toBe('txn-uuid-123');
138
- expect(result.documentNumber).toBe('DOC-001');
139
- expect(result.documentType).toBe('sell');
140
- expect(result.matched).toBe(true);
141
- expect(result.grossValue).toBe(150);
142
- expect(result.pointsEarned).toBe(150);
143
- });
144
- it('should extract documentNumber from header', async () => {
145
- const mockAxios = getMockAxios();
146
- mockAxios.onGet('/default/transaction/txn-uuid').reply(200, {
147
- transactionId: 'txn-uuid',
148
- header: {
149
- documentNumber: 'DOC-FROM-HEADER',
150
- documentType: 'sell',
151
- purchasedAt: '2026-01-15T10:00:00Z',
152
- },
153
- items: [],
154
- matched: true,
155
- grossValue: 100,
156
- });
157
- const result = await transactionGet({ transactionId: 'txn-uuid' });
158
- expect(result.documentNumber).toBe('DOC-FROM-HEADER');
159
- });
160
- });
161
- describe('transactionList', () => {
162
- it('should list transactions from items array', async () => {
163
- const mockAxios = getMockAxios();
164
- mockAxios.onGet('/default/transaction').reply(200, transactionFixtures.listResponse);
165
- const result = await transactionList({});
166
- expect(result.transactions).toHaveLength(2);
167
- expect(result.transactions[0].transactionId).toBe('txn-uuid-1');
168
- expect(result.transactions[0].documentNumber).toBe('DOC-001');
169
- expect(result.transactions[0].matched).toBe(true);
170
- });
171
- it('should include pagination params', async () => {
172
- const mockAxios = getMockAxios();
173
- mockAxios.onGet(/\/default\/transaction/).reply(200, transactionFixtures.listResponse);
174
- await transactionList({ page: 2, perPage: 25 });
175
- const url = mockAxios.history.get[0].url;
176
- expect(url).toContain('_page=2');
177
- expect(url).toContain('_itemsOnPage=25');
178
- });
179
- it('should include filter params', async () => {
180
- const mockAxios = getMockAxios();
181
- mockAxios.onGet(/\/default\/transaction/).reply(200, transactionFixtures.listResponse);
182
- await transactionList({
183
- customerId: 'member-uuid',
184
- documentNumber: 'DOC-001',
185
- documentType: 'sell',
186
- matched: true,
187
- });
188
- const url = mockAxios.history.get[0].url;
189
- expect(url).toContain('customerId=member-uuid');
190
- expect(url).toContain('header%3AdocumentNumber=DOC-001');
191
- expect(url).toContain('header%3AdocumentType=sell');
192
- expect(url).toContain('matched=true');
193
- });
194
- });
195
- describe('transactionAssignMember', () => {
196
- it('should assign transaction to member by customerId', async () => {
197
- const mockAxios = getMockAxios();
198
- mockAxios.onPost('/default/transaction/customer/assign').reply(200, transactionFixtures.assignResponse);
199
- const result = await transactionAssignMember({
200
- documentNumber: 'DOC-001',
201
- customerId: 'member-uuid',
202
- });
203
- expect(result.transactionId).toBe('txn-uuid-123');
204
- expect(result.customerId).toBe('member-uuid');
205
- const requestData = JSON.parse(mockAxios.history.post[0].data);
206
- expect(requestData.transactionDocumentNumber).toBe('DOC-001');
207
- expect(requestData.customerId).toBe('member-uuid');
208
- });
209
- it('should assign by loyalty card number', async () => {
210
- const mockAxios = getMockAxios();
211
- mockAxios.onPost('/default/transaction/customer/assign').reply(200, transactionFixtures.assignResponse);
212
- await transactionAssignMember({
213
- documentNumber: 'DOC-001',
214
- loyaltyCardNumber: 'CARD123456',
215
- });
216
- const requestData = JSON.parse(mockAxios.history.post[0].data);
217
- expect(requestData.customerLoyaltyCardNumber).toBe('CARD123456');
218
- });
219
- it('should assign by phone number', async () => {
220
- const mockAxios = getMockAxios();
221
- mockAxios.onPost('/default/transaction/customer/assign').reply(200, transactionFixtures.assignResponse);
222
- await transactionAssignMember({
223
- documentNumber: 'DOC-001',
224
- phone: '+1234567890',
225
- });
226
- const requestData = JSON.parse(mockAxios.history.post[0].data);
227
- expect(requestData.customerPhoneNumber).toBe('+1234567890');
228
- });
229
- it('should throw error when no identifier provided', async () => {
230
- await expect(transactionAssignMember({
231
- documentNumber: 'DOC-001',
232
- })).rejects.toThrow('At least one member identifier required');
233
- });
234
- });
235
- });
@@ -1,84 +0,0 @@
1
- /**
2
- * Cursor-based pagination utilities for Open Loyalty MCP.
3
- *
4
- * Since the Open Loyalty API only supports offset-based pagination (_page/_itemsOnPage),
5
- * we implement client-side cursor emulation by encoding pagination state into opaque cursors.
6
- */
7
- /**
8
- * Internal cursor data structure.
9
- * Encoded as base64url JSON for the external cursor string.
10
- */
11
- export interface CursorData {
12
- /** Page number (1-indexed) */
13
- p: number;
14
- /** Items per page */
15
- pp: number;
16
- /** Version for future migrations */
17
- v: number;
18
- /** Optional timestamp for expiration (not currently enforced) */
19
- ts?: number;
20
- }
21
- /**
22
- * Pagination metadata returned in list responses.
23
- */
24
- export interface PaginationMeta {
25
- /** Current cursor (encodes current page state) */
26
- cursor: string;
27
- /** Cursor for the next page, or undefined if no more pages */
28
- nextCursor: string | undefined;
29
- /** Whether there are more items after the current page */
30
- hasMore: boolean;
31
- }
32
- /**
33
- * Encode pagination state into an opaque cursor string.
34
- * Uses base64url encoding of JSON data.
35
- *
36
- * @param data - Pagination state to encode
37
- * @returns Opaque cursor string
38
- */
39
- export declare function encodeCursor(data: Omit<CursorData, 'v'>): string;
40
- /**
41
- * Decode an opaque cursor string back to pagination state.
42
- *
43
- * @param cursor - Opaque cursor string
44
- * @returns Decoded pagination state, or null if invalid
45
- */
46
- export declare function decodeCursor(cursor: string): CursorData | null;
47
- /**
48
- * Build pagination metadata for a list response.
49
- *
50
- * @param page - Current page number (1-indexed)
51
- * @param perPage - Items per page
52
- * @param itemCount - Number of items returned in current page
53
- * @param total - Total number of items (from API response, may be undefined)
54
- * @returns Pagination metadata with cursors
55
- */
56
- export declare function buildPaginationMeta(page: number, perPage: number, itemCount: number, total?: number): PaginationMeta;
57
- /**
58
- * Pagination input that supports both cursor and page/perPage.
59
- */
60
- export interface PaginationInput {
61
- /** Opaque cursor from previous response (takes priority over page/perPage) */
62
- cursor?: string;
63
- /** Page number (1-indexed, used if no cursor) */
64
- page?: number;
65
- /** Items per page (used if no cursor) */
66
- perPage?: number;
67
- }
68
- /**
69
- * Resolved pagination parameters for API calls.
70
- */
71
- export interface ResolvedPagination {
72
- /** Page number to request (1-indexed) */
73
- page: number;
74
- /** Items per page */
75
- perPage: number;
76
- }
77
- /**
78
- * Resolve pagination input to concrete page/perPage values.
79
- * If a cursor is provided and valid, it takes priority over page/perPage.
80
- *
81
- * @param input - Pagination input with optional cursor
82
- * @returns Resolved page and perPage values
83
- */
84
- export declare function resolvePaginationInput(input: PaginationInput): ResolvedPagination;
@@ -1,117 +0,0 @@
1
- /**
2
- * Cursor-based pagination utilities for Open Loyalty MCP.
3
- *
4
- * Since the Open Loyalty API only supports offset-based pagination (_page/_itemsOnPage),
5
- * we implement client-side cursor emulation by encoding pagination state into opaque cursors.
6
- */
7
- /** Current cursor format version */
8
- const CURSOR_VERSION = 1;
9
- /**
10
- * Encode pagination state into an opaque cursor string.
11
- * Uses base64url encoding of JSON data.
12
- *
13
- * @param data - Pagination state to encode
14
- * @returns Opaque cursor string
15
- */
16
- export function encodeCursor(data) {
17
- const cursorData = {
18
- ...data,
19
- v: CURSOR_VERSION,
20
- };
21
- const json = JSON.stringify(cursorData);
22
- // Use base64url encoding (URL-safe base64)
23
- return Buffer.from(json, 'utf-8')
24
- .toString('base64')
25
- .replace(/\+/g, '-')
26
- .replace(/\//g, '_')
27
- .replace(/=+$/, '');
28
- }
29
- /**
30
- * Decode an opaque cursor string back to pagination state.
31
- *
32
- * @param cursor - Opaque cursor string
33
- * @returns Decoded pagination state, or null if invalid
34
- */
35
- export function decodeCursor(cursor) {
36
- try {
37
- // Restore base64 from base64url
38
- let base64 = cursor.replace(/-/g, '+').replace(/_/g, '/');
39
- // Add padding if needed
40
- const paddingNeeded = (4 - (base64.length % 4)) % 4;
41
- base64 += '='.repeat(paddingNeeded);
42
- const json = Buffer.from(base64, 'base64').toString('utf-8');
43
- const data = JSON.parse(json);
44
- // Validate required fields
45
- if (typeof data.p !== 'number' || data.p < 1)
46
- return null;
47
- if (typeof data.pp !== 'number' || data.pp < 1)
48
- return null;
49
- if (typeof data.v !== 'number')
50
- return null;
51
- return data;
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- /**
58
- * Build pagination metadata for a list response.
59
- *
60
- * @param page - Current page number (1-indexed)
61
- * @param perPage - Items per page
62
- * @param itemCount - Number of items returned in current page
63
- * @param total - Total number of items (from API response, may be undefined)
64
- * @returns Pagination metadata with cursors
65
- */
66
- export function buildPaginationMeta(page, perPage, itemCount, total) {
67
- // Build current cursor
68
- const cursor = encodeCursor({ p: page, pp: perPage });
69
- // Determine if there are more pages
70
- let hasMore = false;
71
- if (total !== undefined && typeof total === 'number') {
72
- // If we have total, calculate if there are more pages
73
- hasMore = page * perPage < total;
74
- }
75
- else {
76
- // If no total, assume more pages if we got a full page of results
77
- hasMore = itemCount >= perPage;
78
- }
79
- // Build next cursor if there are more pages
80
- const nextCursor = hasMore
81
- ? encodeCursor({ p: page + 1, pp: perPage })
82
- : undefined;
83
- return {
84
- cursor,
85
- nextCursor,
86
- hasMore,
87
- };
88
- }
89
- /** Default page number if not specified */
90
- const DEFAULT_PAGE = 1;
91
- /** Default items per page if not specified */
92
- const DEFAULT_PER_PAGE = 25;
93
- /**
94
- * Resolve pagination input to concrete page/perPage values.
95
- * If a cursor is provided and valid, it takes priority over page/perPage.
96
- *
97
- * @param input - Pagination input with optional cursor
98
- * @returns Resolved page and perPage values
99
- */
100
- export function resolvePaginationInput(input) {
101
- // If cursor is provided, try to decode it
102
- if (input.cursor) {
103
- const cursorData = decodeCursor(input.cursor);
104
- if (cursorData) {
105
- return {
106
- page: cursorData.p,
107
- perPage: cursorData.pp,
108
- };
109
- }
110
- // Invalid cursor - fall through to page/perPage
111
- }
112
- // Use page/perPage with defaults
113
- return {
114
- page: input.page ?? DEFAULT_PAGE,
115
- perPage: input.perPage ?? DEFAULT_PER_PAGE,
116
- };
117
- }