@stardeck-customer-apps/testing 0.21.0 → 0.23.0

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/dist/setup.mjs CHANGED
@@ -28,6 +28,7 @@ var state = globalSingleton("state", () => ({
28
28
  db: null,
29
29
  lastHarnessDataStoreManifest: null,
30
30
  currentUser: null,
31
+ liffId: null,
31
32
  sessions: /* @__PURE__ */ new Map(),
32
33
  refreshSessions: /* @__PURE__ */ new Map(),
33
34
  emails: [],
@@ -52,6 +53,11 @@ var state = globalSingleton("state", () => ({
52
53
  boltIntentCounter: 0,
53
54
  boltCharges: [],
54
55
  boltChargeCounter: 0,
56
+ directCharges: /* @__PURE__ */ new Map(),
57
+ directChargeCounter: 0,
58
+ cardTokens: /* @__PURE__ */ new Map(),
59
+ cardTokenCounter: 0,
60
+ cardTokenizationEnabled: true,
55
61
  uploads: [],
56
62
  uploadCounter: 0,
57
63
  storageFiles: /* @__PURE__ */ new Map(),
@@ -1201,6 +1207,297 @@ async function handleIdentitiesRequest(request, subPath) {
1201
1207
  return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
1202
1208
  }
1203
1209
 
1210
+ // src/simulator/beam-direct-charges.ts
1211
+ var SIM_BEAM_HOST = "beam.stardeck.test";
1212
+ var SIM_BEAM_MERCHANT_ID = "sim_merchant";
1213
+ var SIM_BEAM_PUBLISHABLE_KEY = "pk_test_sim";
1214
+ var DIRECT_CHARGE_METHODS = /* @__PURE__ */ new Set([
1215
+ "CARD",
1216
+ "CARD_INSTALLMENTS",
1217
+ "CARD_TOKEN",
1218
+ "CARD_TOKEN_INSTALLMENTS",
1219
+ "QR_PROMPT_PAY",
1220
+ "ALIPAY",
1221
+ "ALIPAY_PLUS",
1222
+ "WECHAT_PAY",
1223
+ "TRUE_MONEY",
1224
+ "LINE_PAY",
1225
+ "SHOPEE_PAY",
1226
+ "KPLUS",
1227
+ "MAKE",
1228
+ "SCB_EASY",
1229
+ "KRUNGSRI_APP",
1230
+ "BANGKOK_BANK_APP",
1231
+ "SPAY_LATER"
1232
+ ]);
1233
+ var TEST_CARDS = {
1234
+ "4111111111111111": { outcome: "SUCCEEDED" },
1235
+ "5372074248113841": { outcome: "SUCCEEDED" },
1236
+ "378282246310005": { outcome: "SUCCEEDED" },
1237
+ "4953261730509988": { outcome: "REDIRECT" },
1238
+ "4389783280144506": { outcome: "REDIRECT" },
1239
+ "4943129900084541": { outcome: "FAILED", failureCode: "CH_INSUFFICIENT_FUNDS" },
1240
+ "4111111111000025": { outcome: "FAILED", failureCode: "CH_CARD_DECLINED" },
1241
+ "4111111111000033": { outcome: "FAILED", failureCode: "CH_CARD_RESTRICTED" }
1242
+ };
1243
+ var SIM_QR_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
1244
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1245
+ function invalid(issues) {
1246
+ return json({ error: "Invalid request body", details: issues }, 400);
1247
+ }
1248
+ var DEVICE_TYPES = /* @__PURE__ */ new Set(["ANDROID", "IOS", "WEB"]);
1249
+ function issue(path, message, code = "custom") {
1250
+ return { path, message, code };
1251
+ }
1252
+ function validateChargeFields(body) {
1253
+ const issues = [];
1254
+ const isInt = (v) => typeof v === "number" && Number.isInteger(v);
1255
+ if (!isInt(body.amount) || body.amount < 1) {
1256
+ issues.push(issue(["amount"], "Number must be greater than 0", "too_small"));
1257
+ }
1258
+ if (typeof body.deploymentId !== "string" || !UUID_RE.test(body.deploymentId)) {
1259
+ issues.push(issue(["deploymentId"], "Invalid deployment ID", "invalid_string"));
1260
+ }
1261
+ if (body.referenceId !== void 0 && (typeof body.referenceId !== "string" || body.referenceId.length > 100)) {
1262
+ issues.push(issue(["referenceId"], "Too long", "too_big"));
1263
+ }
1264
+ const expiry = body.expiryDurationInSec;
1265
+ if (expiry !== void 0 && (!isInt(expiry) || expiry < 60 || expiry > 3600)) {
1266
+ issues.push(issue(["expiryDurationInSec"], "Must be an integer from 60 to 3600", "too_big"));
1267
+ }
1268
+ if (body.returnUrl !== void 0 && (typeof body.returnUrl !== "string" || !URL.canParse(body.returnUrl))) {
1269
+ issues.push(issue(["returnUrl"], "Invalid URL", "invalid_format"));
1270
+ }
1271
+ if (body.deviceType !== void 0 && !DEVICE_TYPES.has(String(body.deviceType))) {
1272
+ issues.push(issue(["deviceType"], "Invalid enum value", "invalid_value"));
1273
+ }
1274
+ const key = body.idempotencyKey;
1275
+ if (key !== void 0 && (typeof key !== "string" || key.length < 1 || key.length > 255)) {
1276
+ issues.push(issue(["idempotencyKey"], "Must be 1 to 255 characters", "too_small"));
1277
+ }
1278
+ return issues;
1279
+ }
1280
+ function validateChargeMethod(body) {
1281
+ const method = body.paymentMethod;
1282
+ const type = method?.paymentMethodType;
1283
+ if (typeof type !== "string" || !DIRECT_CHARGE_METHODS.has(type)) {
1284
+ return [issue(["paymentMethod", "paymentMethodType"], "Invalid enum value", "invalid_value")];
1285
+ }
1286
+ const issues = [];
1287
+ const isToken = type === "CARD_TOKEN" || type === "CARD_TOKEN_INSTALLMENTS";
1288
+ const token = method?.cardToken;
1289
+ if (isToken && (typeof token?.cardTokenId !== "string" || token.cardTokenId === "")) {
1290
+ issues.push(
1291
+ issue(["paymentMethod", "cardToken"], `${type} requires paymentMethod.cardToken.cardTokenId`)
1292
+ );
1293
+ }
1294
+ if (token?.securityCode !== void 0 && !/^\d{3,4}$/.test(String(token.securityCode))) {
1295
+ issues.push(
1296
+ issue(
1297
+ ["paymentMethod", "cardToken", "securityCode"],
1298
+ "Invalid security code",
1299
+ "invalid_format"
1300
+ )
1301
+ );
1302
+ }
1303
+ if (type === "CARD_TOKEN_INSTALLMENTS" && ![3, 4, 6, 10].includes(method?.installmentPeriod)) {
1304
+ issues.push(
1305
+ issue(
1306
+ ["paymentMethod", "installmentPeriod"],
1307
+ "CARD_TOKEN_INSTALLMENTS requires paymentMethod.installmentPeriod (3, 4, 6 or 10)"
1308
+ )
1309
+ );
1310
+ }
1311
+ if (type !== "QR_PROMPT_PAY" && !body.returnUrl) {
1312
+ issues.push(
1313
+ issue(
1314
+ ["returnUrl"],
1315
+ `${type} sends the shopper away to pay; returnUrl is required to bring them back`
1316
+ )
1317
+ );
1318
+ }
1319
+ if (type === "CARD" || type === "CARD_INSTALLMENTS") {
1320
+ issues.push(
1321
+ issue(
1322
+ ["paymentMethod", "paymentMethodType"],
1323
+ `${type} takes raw card data; tokenize the card in the browser and charge CARD_TOKEN instead`
1324
+ )
1325
+ );
1326
+ }
1327
+ return issues;
1328
+ }
1329
+ function validateDirectCharge(body) {
1330
+ return [...validateChargeFields(body), ...validateChargeMethod(body)];
1331
+ }
1332
+ function chargeResponse(charge) {
1333
+ const response = {
1334
+ chargeId: charge.id,
1335
+ actionRequired: charge.actionRequired,
1336
+ paymentMethodType: charge.paymentMethodType
1337
+ };
1338
+ if (charge.actionRequired === "ENCODED_IMAGE") {
1339
+ response.encodedImage = {
1340
+ imageBase64Encoded: SIM_QR_PNG,
1341
+ rawData: `00020101021230SIM${charge.id}`,
1342
+ expiry: charge.expiresAt
1343
+ };
1344
+ }
1345
+ if (charge.actionRequired === "REDIRECT") {
1346
+ response.redirect = { redirectUrl: charge.redirectUrl };
1347
+ response.redirectUrl = charge.redirectUrl;
1348
+ }
1349
+ return response;
1350
+ }
1351
+ function nextChargeId() {
1352
+ state.directChargeCounter += 1;
1353
+ return `ch_sim_${state.directChargeCounter}`;
1354
+ }
1355
+ function createCharge(body) {
1356
+ const method = body.paymentMethod;
1357
+ const type = method.paymentMethodType;
1358
+ const id = nextChargeId();
1359
+ const now3 = /* @__PURE__ */ new Date();
1360
+ const expirySec = typeof body.expiryDurationInSec === "number" ? body.expiryDurationInSec : 30 * 60;
1361
+ const returnUrl = typeof body.returnUrl === "string" ? body.returnUrl : null;
1362
+ let actionRequired = "REDIRECT";
1363
+ let status = "PENDING";
1364
+ let failureCode = null;
1365
+ let cardLast4 = null;
1366
+ if (type === "QR_PROMPT_PAY") {
1367
+ actionRequired = "ENCODED_IMAGE";
1368
+ } else if (type === "CARD_TOKEN" || type === "CARD_TOKEN_INSTALLMENTS") {
1369
+ const token = state.cardTokens.get(method.cardToken.cardTokenId);
1370
+ if (!token) {
1371
+ return json({ error: "Card token not found", code: "CARD_TOKEN_NOT_FOUND" }, 404);
1372
+ }
1373
+ cardLast4 = token.last4;
1374
+ const card = TEST_CARDS[token.pan] ?? {
1375
+ outcome: "FAILED",
1376
+ failureCode: "CH_PROCESSING_FAILED"
1377
+ };
1378
+ if (card.outcome === "REDIRECT") {
1379
+ actionRequired = "REDIRECT";
1380
+ } else {
1381
+ actionRequired = "NONE";
1382
+ status = card.outcome;
1383
+ failureCode = card.failureCode ?? null;
1384
+ }
1385
+ }
1386
+ const charge = {
1387
+ id,
1388
+ status,
1389
+ amount: body.amount,
1390
+ currency: String(body.currency ?? "THB"),
1391
+ paymentMethodType: type,
1392
+ referenceId: typeof body.referenceId === "string" ? body.referenceId : null,
1393
+ deviceType: typeof body.deviceType === "string" ? body.deviceType : null,
1394
+ returnUrl,
1395
+ actionRequired,
1396
+ redirectUrl: actionRequired === "REDIRECT" ? `https://${SIM_BEAM_HOST}/pay/${id}${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ""}` : null,
1397
+ expiresAt: new Date(now3.getTime() + expirySec * 1e3).toISOString(),
1398
+ failureCode,
1399
+ cardLast4,
1400
+ idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : null,
1401
+ createdAt: now3.toISOString()
1402
+ };
1403
+ state.directCharges.set(id, charge);
1404
+ return charge;
1405
+ }
1406
+ async function handleDirectChargeRequest(request, pathname) {
1407
+ if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges$/.test(pathname)) {
1408
+ const body = await readJsonBody(request);
1409
+ const issues = validateDirectCharge(body);
1410
+ if (issues.length > 0) return invalid(issues);
1411
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : null;
1412
+ if (key) {
1413
+ const existing = [...state.directCharges.values()].find((c) => c.idempotencyKey === key);
1414
+ if (existing) return json(chargeResponse(existing));
1415
+ }
1416
+ const created = createCharge(body);
1417
+ if (created instanceof Response) return created;
1418
+ return json(chargeResponse(created));
1419
+ }
1420
+ const getMatch = pathname.match(/^\/api\/store\/beam\/[^/]+\/charges\/([^/]+)$/);
1421
+ if (request.method === "GET" && getMatch) {
1422
+ const charge = state.directCharges.get(decodeURIComponent(getMatch[1]));
1423
+ if (!charge) return json({ error: "Charge not found", code: "NOT_FOUND" }, 404);
1424
+ return json({ charge: toChargeDetail(charge) });
1425
+ }
1426
+ if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/card-tokenization$/.test(pathname)) {
1427
+ if (!state.cardTokenizationEnabled) {
1428
+ return json(
1429
+ {
1430
+ error: "Add the Beam publishable key to this merchant account to take cards in your own checkout",
1431
+ code: "CARD_TOKENIZATION_NOT_CONFIGURED"
1432
+ },
1433
+ 409
1434
+ );
1435
+ }
1436
+ return json({
1437
+ merchantId: SIM_BEAM_MERCHANT_ID,
1438
+ publishableKey: SIM_BEAM_PUBLISHABLE_KEY,
1439
+ tokenizeUrl: `https://${SIM_BEAM_HOST}/client/v1/card-tokens`,
1440
+ isSandbox: true
1441
+ });
1442
+ }
1443
+ return null;
1444
+ }
1445
+ function toChargeDetail(charge) {
1446
+ return {
1447
+ id: charge.id,
1448
+ chargeId: charge.id,
1449
+ status: charge.status,
1450
+ amount: charge.amount,
1451
+ currency: charge.currency,
1452
+ paymentMethodType: charge.paymentMethodType,
1453
+ ...charge.referenceId !== null ? { referenceId: charge.referenceId } : {},
1454
+ ...charge.failureCode ? { failureCode: charge.failureCode } : {},
1455
+ source: "API",
1456
+ createdAt: charge.createdAt
1457
+ };
1458
+ }
1459
+ async function handleCardTokenRequest(request, url) {
1460
+ if (request.method !== "POST" || url.pathname !== "/client/v1/card-tokens") {
1461
+ return json({ error: `No Beam simulator for ${request.method} ${url.pathname}` }, 404);
1462
+ }
1463
+ const expected = `Basic ${btoa(`${SIM_BEAM_MERCHANT_ID}:${SIM_BEAM_PUBLISHABLE_KEY}`)}`;
1464
+ if (request.headers.get("Authorization") !== expected) {
1465
+ return json(
1466
+ {
1467
+ message: "invalid credentials",
1468
+ error: { errorCode: "INVALID_CREDENTIALS_ERROR", errorMessage: "invalid credentials" }
1469
+ },
1470
+ 401
1471
+ );
1472
+ }
1473
+ const body = await readJsonBody(request);
1474
+ const pan = typeof body.pan === "string" ? body.pan : "";
1475
+ const month = body.expiryMonth;
1476
+ const year = body.expiryYear;
1477
+ if (!/^\d{12,19}$/.test(pan) || typeof month !== "number" || month < 1 || month > 12 || typeof year !== "number" || year < 0 || year > 99) {
1478
+ return json(
1479
+ {
1480
+ message: "inputs are failing validation",
1481
+ error: {
1482
+ errorCode: "API_VALIDATION_ERROR",
1483
+ errorMessage: "inputs are failing validation"
1484
+ }
1485
+ },
1486
+ 400
1487
+ );
1488
+ }
1489
+ state.cardTokenCounter += 1;
1490
+ const token = {
1491
+ id: `ctok_sim_${state.cardTokenCounter}`,
1492
+ pan,
1493
+ last4: pan.slice(-4),
1494
+ expiryMonth: month,
1495
+ expiryYear: year
1496
+ };
1497
+ state.cardTokens.set(token.id, token);
1498
+ return json({ id: token.id });
1499
+ }
1500
+
1204
1501
  // src/simulator/payments.ts
1205
1502
  import crypto5 from "crypto";
1206
1503
 
@@ -1228,7 +1525,7 @@ var BEAM_BOLT_PAYMENT_METHODS = [
1228
1525
  "SPAY_LATER"
1229
1526
  ];
1230
1527
  var BEAM_BOLT_INTENT_STATUSES = ["PENDING", "PAID", "FAILED", "CANCELED", "EXPIRED"];
1231
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1528
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1232
1529
  function payErr(error, status = 400, code, details) {
1233
1530
  const body = { error };
1234
1531
  if (code) body.code = code;
@@ -1242,6 +1539,12 @@ function paymentOperationForRequest(request, pathname) {
1242
1539
  if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/payment-links\/[^/]+$/.test(pathname)) {
1243
1540
  return "beam.getPaymentLinkStatus";
1244
1541
  }
1542
+ if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges$/.test(pathname)) {
1543
+ return "beam.createDirectCharge";
1544
+ }
1545
+ if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/charges\/[^/]+$/.test(pathname)) {
1546
+ return "beam.getCharge";
1547
+ }
1245
1548
  if (request.method === "POST" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout$/.test(pathname)) {
1246
1549
  return "stripe.createCheckout";
1247
1550
  }
@@ -1382,6 +1685,8 @@ async function handlePaymentsRequest(request, url) {
1382
1685
  const pathname = url.pathname;
1383
1686
  const injectedFailure = consumePaymentFailure(request, pathname);
1384
1687
  if (injectedFailure) return injectedFailure;
1688
+ const directChargeResponse = await handleDirectChargeRequest(request, pathname);
1689
+ if (directChargeResponse) return directChargeResponse;
1385
1690
  if (/\/billing-portal$/.test(pathname)) {
1386
1691
  return payErr("Not found", 404, "NOT_FOUND");
1387
1692
  }
@@ -1478,7 +1783,7 @@ async function handlePaymentsRequest(request, url) {
1478
1783
  code: "too_small"
1479
1784
  });
1480
1785
  }
1481
- if (typeof body.deploymentId !== "string" || !UUID_RE.test(body.deploymentId)) {
1786
+ if (typeof body.deploymentId !== "string" || !UUID_RE2.test(body.deploymentId)) {
1482
1787
  issues.push({
1483
1788
  path: ["deploymentId"],
1484
1789
  message: "Invalid uuid",
@@ -2406,6 +2711,9 @@ async function handleSimulatedRequest(request, url) {
2406
2711
  if (isStorageHost) {
2407
2712
  return handleStorageRequest(request, url);
2408
2713
  }
2714
+ if (url.hostname === SIM_BEAM_HOST) {
2715
+ return handleCardTokenRequest(request, url);
2716
+ }
2409
2717
  if (isEmail && request.method === "POST") {
2410
2718
  return handleEmailSend(request);
2411
2719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  "build": "tsup src/index.ts src/config.ts src/setup.ts src/next/headers-shim.ts src/server-only-shim.ts --format cjs,esm --dts --no-splitting",
49
49
  "dev": "tsup src/index.ts src/config.ts src/setup.ts src/next/headers-shim.ts src/server-only-shim.ts --format cjs,esm --dts --no-splitting --watch",
50
50
  "format": "prettier --write . && oxlint . --fix",
51
- "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
51
+ "typecheck": "tsgo --noEmit && tsgo -p tsconfig.test.json",
52
52
  "lint": "oxlint src/",
53
53
  "test": "vitest run",
54
54
  "test:watch": "vitest"