opticedge-cloud-utils 1.1.31 → 1.1.33

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.
@@ -2,6 +2,10 @@ type CardParts = {
2
2
  name: string;
3
3
  number: string | null;
4
4
  };
5
+ interface ParsedVariantName {
6
+ name: string;
7
+ variant: string;
8
+ }
5
9
  /**
6
10
  * Split a card label into `{ name, number }`.
7
11
  *
@@ -15,4 +19,6 @@ type CardParts = {
15
19
  * 3. If no valid number found — returns `{ name: original, number: null }`.
16
20
  */
17
21
  export declare function splitNameAndNumber(input: string, realNumber?: string | null): CardParts;
22
+ export declare function parseCardVariant(value: string): ParsedVariantName;
23
+ export declare function productExists(productId: number, token: string, fetchTimeoutMs: number): Promise<boolean | null>;
18
24
  export {};
@@ -1,7 +1,13 @@
1
1
  "use strict";
2
+ // src/pricecharting.ts
2
3
  Object.defineProperty(exports, "__esModule", { value: true });
3
4
  exports.splitNameAndNumber = splitNameAndNumber;
5
+ exports.parseCardVariant = parseCardVariant;
6
+ exports.productExists = productExists;
7
+ const logger_1 = require("./logger");
4
8
  const regex_1 = require("./regex");
9
+ const PRODUCT_NOT_FOUND_MESSAGE = 'no such product';
10
+ const PRODUCT_API_URL = 'https://www.pricecharting.com/api/product';
5
11
  /**
6
12
  * Split a card label into `{ name, number }`.
7
13
  *
@@ -48,3 +54,96 @@ function splitNameAndNumber(input, realNumber) {
48
54
  return { name: s, number: null };
49
55
  return { name: namePart, number: numberPart };
50
56
  }
57
+ function parseCardVariant(value) {
58
+ const trimmedValue = value.trim();
59
+ // Matches names ending with a bracketed variant:
60
+ // Arcanine [Reverse Holo]
61
+ const match = trimmedValue.match(/^(.*?)\s+\[([^\]]+)\]\s*$/);
62
+ if (!match) {
63
+ return {
64
+ name: trimmedValue,
65
+ variant: 'Regular'
66
+ };
67
+ }
68
+ return {
69
+ name: match[1].trim(),
70
+ variant: match[2].trim()
71
+ };
72
+ }
73
+ async function productExists(productId, token, fetchTimeoutMs) {
74
+ if (!Number.isInteger(productId) || productId <= 0) {
75
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] invalid productId', { productId });
76
+ return null;
77
+ }
78
+ if (!token?.trim()) {
79
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] missing token', { productId });
80
+ return null;
81
+ }
82
+ if (!Number.isFinite(fetchTimeoutMs) || fetchTimeoutMs < 1000) {
83
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] invalid fetch timeout', {
84
+ productId,
85
+ fetchTimeoutMs
86
+ });
87
+ return null;
88
+ }
89
+ const params = new URLSearchParams({
90
+ t: token,
91
+ id: String(productId)
92
+ });
93
+ const controller = new AbortController();
94
+ const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
95
+ try {
96
+ const resp = await fetch(`${PRODUCT_API_URL}?${params.toString()}`, {
97
+ method: 'GET',
98
+ signal: controller.signal
99
+ });
100
+ clearTimeout(timeout);
101
+ if (resp.status === 404)
102
+ return false;
103
+ if (resp.status !== 200) {
104
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] unexpected status code', {
105
+ productId,
106
+ statusCode: resp.status
107
+ });
108
+ return null;
109
+ }
110
+ let data;
111
+ try {
112
+ data = await resp.json();
113
+ }
114
+ catch (err) {
115
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] failed to parse JSON', {
116
+ productId,
117
+ traceback: err instanceof Error ? err.stack : String(err)
118
+ });
119
+ return null;
120
+ }
121
+ if (typeof data !== 'object' || data === null) {
122
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] unexpected response body', {
123
+ productId,
124
+ bodyType: typeof data
125
+ });
126
+ return null;
127
+ }
128
+ const status = (data.status || '').toLowerCase();
129
+ const errText = ((data.error || '') + ' ' + (data['error-message'] || '')).toLowerCase();
130
+ if (status === 'error') {
131
+ if (errText.includes(PRODUCT_NOT_FOUND_MESSAGE.toLowerCase()))
132
+ return false;
133
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] API returned error', { productId, error: errText });
134
+ return null;
135
+ }
136
+ return true;
137
+ }
138
+ catch (unknownErr) {
139
+ const err = unknownErr instanceof Error ? unknownErr : new Error(String(unknownErr));
140
+ (0, logger_1.log)(logger_1.LogLevel.WARNING, '[productExists] request failed', {
141
+ productId,
142
+ traceback: err.stack ?? err.message
143
+ });
144
+ return null;
145
+ }
146
+ finally {
147
+ clearTimeout(timeout);
148
+ }
149
+ }
package/package.json CHANGED
@@ -1,46 +1,46 @@
1
- {
2
- "name": "opticedge-cloud-utils",
3
- "version": "1.1.31",
4
- "description": "Common utilities for cloud functions",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "scripts": {
8
- "clean": "rimraf dist coverage *.tsbuildinfo",
9
- "build": "npm run clean && tsc",
10
- "prepare": "npm run build",
11
- "test": "jest --coverage",
12
- "lint": "eslint . --ext .ts --fix",
13
- "format": "prettier --write .",
14
- "fix": "npm run lint && npm run format"
15
- },
16
- "author": "Evans Musonda",
17
- "license": "MIT",
18
- "dependencies": {
19
- "@google-cloud/pubsub": "^5.2.0",
20
- "@google-cloud/secret-manager": "^6.0.1",
21
- "@google-cloud/tasks": "^6.1.0",
22
- "axios": "^1.10.0"
23
- },
24
- "devDependencies": {
25
- "@google-cloud/functions-framework": "*",
26
- "@types/jest": "^29.5.14",
27
- "@types/node": "^22.15.23",
28
- "@typescript-eslint/eslint-plugin": "^8.33.0",
29
- "@typescript-eslint/parser": "^8.33.0",
30
- "eslint": "^8.57.1",
31
- "eslint-config-prettier": "^10.1.5",
32
- "eslint-plugin-jest": "^28.11.1",
33
- "eslint-plugin-prettier": "^5.4.0",
34
- "google-auth-library": "*",
35
- "jest": "^29.7.0",
36
- "mongodb": "*",
37
- "prettier": "^3.5.3",
38
- "ts-jest": "^29.3.4",
39
- "typescript": "^5.8.3"
40
- },
41
- "peerDependencies": {
42
- "@google-cloud/functions-framework": "^5.0.2",
43
- "google-auth-library": "^10.6.2",
44
- "mongodb": "^7.1.1"
45
- }
46
- }
1
+ {
2
+ "name": "opticedge-cloud-utils",
3
+ "version": "1.1.33",
4
+ "description": "Common utilities for cloud functions",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "clean": "rimraf dist coverage *.tsbuildinfo",
9
+ "build": "npm run clean && tsc",
10
+ "prepare": "npm run build",
11
+ "test": "jest --coverage",
12
+ "lint": "eslint . --ext .ts --fix",
13
+ "format": "prettier --write .",
14
+ "fix": "npm run lint && npm run format"
15
+ },
16
+ "author": "Evans Musonda",
17
+ "license": "MIT",
18
+ "dependencies": {
19
+ "@google-cloud/pubsub": "^5.2.0",
20
+ "@google-cloud/secret-manager": "^6.0.1",
21
+ "@google-cloud/tasks": "^6.1.0",
22
+ "axios": "^1.10.0"
23
+ },
24
+ "devDependencies": {
25
+ "@google-cloud/functions-framework": "*",
26
+ "@types/jest": "^29.5.14",
27
+ "@types/node": "^22.15.23",
28
+ "@typescript-eslint/eslint-plugin": "^8.33.0",
29
+ "@typescript-eslint/parser": "^8.33.0",
30
+ "eslint": "^8.57.1",
31
+ "eslint-config-prettier": "^10.1.5",
32
+ "eslint-plugin-jest": "^28.11.1",
33
+ "eslint-plugin-prettier": "^5.4.0",
34
+ "google-auth-library": "*",
35
+ "jest": "^29.7.0",
36
+ "mongodb": "*",
37
+ "prettier": "^3.5.3",
38
+ "ts-jest": "^29.3.4",
39
+ "typescript": "^5.8.3"
40
+ },
41
+ "peerDependencies": {
42
+ "@google-cloud/functions-framework": "^5.0.2",
43
+ "google-auth-library": "^10.6.2",
44
+ "mongodb": "^7.1.1"
45
+ }
46
+ }
@@ -1,7 +1,24 @@
1
+ // src/pricecharting.ts
2
+
3
+ import { log, LogLevel } from './logger'
1
4
  import { escapeForRegex } from './regex'
2
5
 
3
6
  type CardParts = { name: string; number: string | null }
4
7
 
8
+ interface ProductExistsResponse {
9
+ status?: string
10
+ error?: string
11
+ 'error-message'?: string
12
+ }
13
+
14
+ interface ParsedVariantName {
15
+ name: string
16
+ variant: string
17
+ }
18
+
19
+ const PRODUCT_NOT_FOUND_MESSAGE = 'no such product'
20
+ const PRODUCT_API_URL = 'https://www.pricecharting.com/api/product'
21
+
5
22
  /**
6
23
  * Split a card label into `{ name, number }`.
7
24
  *
@@ -49,3 +66,112 @@ export function splitNameAndNumber(input: string, realNumber?: string | null): C
49
66
 
50
67
  return { name: namePart, number: numberPart }
51
68
  }
69
+
70
+ export function parseCardVariant(value: string): ParsedVariantName {
71
+ const trimmedValue = value.trim()
72
+
73
+ // Matches names ending with a bracketed variant:
74
+ // Arcanine [Reverse Holo]
75
+ const match = trimmedValue.match(/^(.*?)\s+\[([^\]]+)\]\s*$/)
76
+
77
+ if (!match) {
78
+ return {
79
+ name: trimmedValue,
80
+ variant: 'Regular'
81
+ }
82
+ }
83
+
84
+ return {
85
+ name: match[1].trim(),
86
+ variant: match[2].trim()
87
+ }
88
+ }
89
+
90
+ export async function productExists(
91
+ productId: number,
92
+ token: string,
93
+ fetchTimeoutMs: number
94
+ ): Promise<boolean | null> {
95
+ if (!Number.isInteger(productId) || productId <= 0) {
96
+ log(LogLevel.WARNING, '[productExists] invalid productId', { productId })
97
+ return null
98
+ }
99
+
100
+ if (!token?.trim()) {
101
+ log(LogLevel.WARNING, '[productExists] missing token', { productId })
102
+ return null
103
+ }
104
+
105
+ if (!Number.isFinite(fetchTimeoutMs) || fetchTimeoutMs < 1000) {
106
+ log(LogLevel.WARNING, '[productExists] invalid fetch timeout', {
107
+ productId,
108
+ fetchTimeoutMs
109
+ })
110
+ return null
111
+ }
112
+
113
+ const params = new URLSearchParams({
114
+ t: token,
115
+ id: String(productId)
116
+ })
117
+
118
+ const controller = new AbortController()
119
+ const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs)
120
+
121
+ try {
122
+ const resp = await fetch(`${PRODUCT_API_URL}?${params.toString()}`, {
123
+ method: 'GET',
124
+ signal: controller.signal
125
+ })
126
+
127
+ clearTimeout(timeout)
128
+
129
+ if (resp.status === 404) return false
130
+ if (resp.status !== 200) {
131
+ log(LogLevel.WARNING, '[productExists] unexpected status code', {
132
+ productId,
133
+ statusCode: resp.status
134
+ })
135
+ return null
136
+ }
137
+
138
+ let data: ProductExistsResponse
139
+ try {
140
+ data = await resp.json()
141
+ } catch (err) {
142
+ log(LogLevel.WARNING, '[productExists] failed to parse JSON', {
143
+ productId,
144
+ traceback: err instanceof Error ? err.stack : String(err)
145
+ })
146
+ return null
147
+ }
148
+
149
+ if (typeof data !== 'object' || data === null) {
150
+ log(LogLevel.WARNING, '[productExists] unexpected response body', {
151
+ productId,
152
+ bodyType: typeof data
153
+ })
154
+ return null
155
+ }
156
+
157
+ const status = (data.status || '').toLowerCase()
158
+ const errText = ((data.error || '') + ' ' + (data['error-message'] || '')).toLowerCase()
159
+
160
+ if (status === 'error') {
161
+ if (errText.includes(PRODUCT_NOT_FOUND_MESSAGE.toLowerCase())) return false
162
+ log(LogLevel.WARNING, '[productExists] API returned error', { productId, error: errText })
163
+ return null
164
+ }
165
+
166
+ return true
167
+ } catch (unknownErr: unknown) {
168
+ const err = unknownErr instanceof Error ? unknownErr : new Error(String(unknownErr))
169
+ log(LogLevel.WARNING, '[productExists] request failed', {
170
+ productId,
171
+ traceback: err.stack ?? err.message
172
+ })
173
+ return null
174
+ } finally {
175
+ clearTimeout(timeout)
176
+ }
177
+ }
@@ -1,6 +1,6 @@
1
- // tests/utils.test.ts
1
+ // tests/pricecharting.test.ts
2
2
 
3
- import { splitNameAndNumber } from '../src/pricecharting'
3
+ import { productExists, splitNameAndNumber } from '../src/pricecharting'
4
4
 
5
5
  describe('splitNameAndNumber (legacy fallback and new realNumber param)', () => {
6
6
  const legacyCases: Array<[string, { name: string; number: string | null }]> = [
@@ -73,3 +73,140 @@ describe('splitNameAndNumber (legacy fallback and new realNumber param)', () =>
73
73
  expect(splitNameAndNumber(input, '123')).toEqual({ name: 'Example', number: 'X123' })
74
74
  })
75
75
  })
76
+
77
+ describe('productExists', () => {
78
+ const originalFetch = global.fetch
79
+
80
+ beforeEach(() => {
81
+ jest.clearAllMocks()
82
+ jest.useRealTimers()
83
+ global.fetch = jest.fn()
84
+ })
85
+
86
+ afterEach(() => {
87
+ global.fetch = originalFetch
88
+ })
89
+
90
+ function mockFetchResponse(status: number, body: unknown) {
91
+ ;(global.fetch as jest.Mock).mockResolvedValue({
92
+ status,
93
+ json: jest.fn().mockResolvedValue(body)
94
+ })
95
+ }
96
+
97
+ test('returns null for invalid productId', async () => {
98
+ const result = await productExists(0, 'token', 30_000)
99
+
100
+ expect(result).toBeNull()
101
+ expect(global.fetch).not.toHaveBeenCalled()
102
+ })
103
+
104
+ test('returns null for missing token', async () => {
105
+ const result = await productExists(123, '', 30_000)
106
+
107
+ expect(result).toBeNull()
108
+ expect(global.fetch).not.toHaveBeenCalled()
109
+ })
110
+
111
+ test('returns null for invalid timeout', async () => {
112
+ const result = await productExists(123, 'token', 30)
113
+
114
+ expect(result).toBeNull()
115
+ expect(global.fetch).not.toHaveBeenCalled()
116
+ })
117
+
118
+ test('returns false when API responds with HTTP 404', async () => {
119
+ mockFetchResponse(404, {
120
+ error: 'No such product',
121
+ 'error-message': 'No such product',
122
+ status: 'error'
123
+ })
124
+
125
+ const result = await productExists(123, 'token', 30_000)
126
+
127
+ expect(result).toBe(false)
128
+ expect(global.fetch).toHaveBeenCalledTimes(1)
129
+ })
130
+
131
+ test('returns true when API responds with 200 and non-error body', async () => {
132
+ mockFetchResponse(200, {
133
+ status: 'success',
134
+ product: {
135
+ id: 123
136
+ }
137
+ })
138
+
139
+ const result = await productExists(123, 'token', 30_000)
140
+
141
+ expect(result).toBe(true)
142
+ expect(global.fetch).toHaveBeenCalledTimes(1)
143
+ })
144
+
145
+ test('returns false when API responds with status error and no such product message', async () => {
146
+ mockFetchResponse(200, {
147
+ status: 'error',
148
+ error: 'No such product',
149
+ 'error-message': 'No such product'
150
+ })
151
+
152
+ const result = await productExists(123, 'token', 30_000)
153
+
154
+ expect(result).toBe(false)
155
+ })
156
+
157
+ test('returns null when API responds with non-200 non-404 status', async () => {
158
+ mockFetchResponse(500, {
159
+ status: 'error',
160
+ error: 'server error'
161
+ })
162
+
163
+ const result = await productExists(123, 'token', 30_000)
164
+
165
+ expect(result).toBeNull()
166
+ })
167
+
168
+ test('returns null when JSON parsing fails', async () => {
169
+ ;(global.fetch as jest.Mock).mockResolvedValue({
170
+ status: 200,
171
+ json: jest.fn().mockRejectedValue(new Error('invalid json'))
172
+ })
173
+
174
+ const result = await productExists(123, 'token', 30_000)
175
+
176
+ expect(result).toBeNull()
177
+ })
178
+
179
+ test('returns null when response body is not an object', async () => {
180
+ mockFetchResponse(200, null)
181
+
182
+ const result = await productExists(123, 'token', 30_000)
183
+
184
+ expect(result).toBeNull()
185
+ })
186
+
187
+ test('returns null when fetch fails', async () => {
188
+ ;(global.fetch as jest.Mock).mockRejectedValue(new Error('network error'))
189
+
190
+ const result = await productExists(123, 'token', 30_000)
191
+
192
+ expect(result).toBeNull()
193
+ })
194
+
195
+ test('calls PriceCharting API with token and product id', async () => {
196
+ mockFetchResponse(200, {
197
+ status: 'success'
198
+ })
199
+
200
+ await productExists(456, 'test-token', 30_000)
201
+
202
+ expect(global.fetch).toHaveBeenCalledTimes(1)
203
+
204
+ const [url, options] = (global.fetch as jest.Mock).mock.calls[0]
205
+
206
+ expect(url).toContain('https://www.pricecharting.com/api/product')
207
+ expect(url).toContain('t=test-token')
208
+ expect(url).toContain('id=456')
209
+ expect(options.method).toBe('GET')
210
+ expect(options.signal).toBeDefined()
211
+ })
212
+ })