@stardeck-customer-apps/testing 0.3.0 → 0.4.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/SKILL.md +50 -0
- package/dist/index.d.mts +110 -67
- package/dist/index.d.ts +110 -67
- package/dist/index.js +754 -136
- package/dist/index.mjs +752 -138
- package/dist/next/headers-shim.js +10 -0
- package/dist/next/headers-shim.mjs +10 -0
- package/dist/setup.js +502 -18
- package/dist/setup.mjs +502 -18
- package/package.json +12 -3
package/dist/setup.mjs
CHANGED
|
@@ -16,6 +16,16 @@ var state = globalSingleton("state", () => ({
|
|
|
16
16
|
emailCounter: 0,
|
|
17
17
|
identities: /* @__PURE__ */ new Map(),
|
|
18
18
|
identityLinks: [],
|
|
19
|
+
checkouts: [],
|
|
20
|
+
checkoutCounter: 0,
|
|
21
|
+
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
22
|
+
paymentLinks: /* @__PURE__ */ new Map(),
|
|
23
|
+
products: [],
|
|
24
|
+
uploads: [],
|
|
25
|
+
uploadCounter: 0,
|
|
26
|
+
storageFiles: /* @__PURE__ */ new Map(),
|
|
27
|
+
presignPending: /* @__PURE__ */ new Map(),
|
|
28
|
+
messages: [],
|
|
19
29
|
allowNetwork: false
|
|
20
30
|
}));
|
|
21
31
|
function requireDb() {
|
|
@@ -31,13 +41,16 @@ function requireDb() {
|
|
|
31
41
|
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
32
42
|
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
33
43
|
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
44
|
+
var STORAGE_TEST_URL = "https://storage.stardeck.test";
|
|
45
|
+
var STORAGE_TEST_HOST = "storage.stardeck.test";
|
|
34
46
|
var TEST_ENV_DEFAULTS = {
|
|
35
47
|
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
36
48
|
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
37
49
|
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
38
50
|
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
39
51
|
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
40
|
-
DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main
|
|
52
|
+
DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
|
|
53
|
+
STORAGE_URL: STORAGE_TEST_URL
|
|
41
54
|
};
|
|
42
55
|
|
|
43
56
|
// src/simulator/hmac.ts
|
|
@@ -85,6 +98,13 @@ function success(data) {
|
|
|
85
98
|
function failure(error, status = 400) {
|
|
86
99
|
return json({ success: false, error }, status);
|
|
87
100
|
}
|
|
101
|
+
async function readJsonBody(request) {
|
|
102
|
+
try {
|
|
103
|
+
return await request.json();
|
|
104
|
+
} catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
88
108
|
|
|
89
109
|
// src/simulator/data-store.ts
|
|
90
110
|
function quoteIdent(name) {
|
|
@@ -565,13 +585,6 @@ function now() {
|
|
|
565
585
|
function linksFor(identityId) {
|
|
566
586
|
return state.identityLinks.filter((l) => l.identityId === identityId);
|
|
567
587
|
}
|
|
568
|
-
async function readBody(request) {
|
|
569
|
-
try {
|
|
570
|
-
return await request.json();
|
|
571
|
-
} catch {
|
|
572
|
-
return {};
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
588
|
function handleList(request) {
|
|
576
589
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
577
590
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
@@ -579,7 +592,7 @@ function handleList(request) {
|
|
|
579
592
|
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
580
593
|
}
|
|
581
594
|
async function handleCreate(request) {
|
|
582
|
-
const body = await
|
|
595
|
+
const body = await readJsonBody(request);
|
|
583
596
|
const type = body.type;
|
|
584
597
|
if (type !== "person" && type !== "account") {
|
|
585
598
|
return failure("type must be 'person' or 'account'");
|
|
@@ -616,7 +629,7 @@ function handleGet(identityId) {
|
|
|
616
629
|
async function handleUpdate(identityId, request) {
|
|
617
630
|
const identity = state.identities.get(identityId);
|
|
618
631
|
if (!identity) return failure("identity not found", 404);
|
|
619
|
-
const body = await
|
|
632
|
+
const body = await readJsonBody(request);
|
|
620
633
|
if (body.displayName !== void 0) {
|
|
621
634
|
identity.displayName = body.displayName;
|
|
622
635
|
}
|
|
@@ -638,7 +651,7 @@ async function handleAttachLink(identityId, request) {
|
|
|
638
651
|
if (identity.status !== "active") {
|
|
639
652
|
return failure("links attach only to active persons");
|
|
640
653
|
}
|
|
641
|
-
const body = await
|
|
654
|
+
const body = await readJsonBody(request);
|
|
642
655
|
const kind = body.kind;
|
|
643
656
|
const externalId = body.externalId;
|
|
644
657
|
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
@@ -681,6 +694,447 @@ async function handleIdentitiesRequest(request, subPath) {
|
|
|
681
694
|
return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
|
|
682
695
|
}
|
|
683
696
|
|
|
697
|
+
// src/simulator/payments.ts
|
|
698
|
+
import crypto5 from "crypto";
|
|
699
|
+
|
|
700
|
+
// src/next/headers-shim.ts
|
|
701
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
702
|
+
var requestScopeStorage = globalSingleton(
|
|
703
|
+
"request-scope",
|
|
704
|
+
() => new AsyncLocalStorage()
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
// src/simulator/webhook-signing.ts
|
|
708
|
+
import crypto4 from "crypto";
|
|
709
|
+
|
|
710
|
+
// src/simulator/payments.ts
|
|
711
|
+
function payErr(error, status = 400, code) {
|
|
712
|
+
return json(code ? { error, code } : { error }, status);
|
|
713
|
+
}
|
|
714
|
+
function nextCheckoutId() {
|
|
715
|
+
state.checkoutCounter += 1;
|
|
716
|
+
return `cs_test_${state.checkoutCounter}`;
|
|
717
|
+
}
|
|
718
|
+
function nextPaymentLinkId() {
|
|
719
|
+
state.checkoutCounter += 1;
|
|
720
|
+
return `plink_test_${state.checkoutCounter}`;
|
|
721
|
+
}
|
|
722
|
+
function seedStripeSession(id, body) {
|
|
723
|
+
const lineItems = body.lineItems ?? [];
|
|
724
|
+
let amountTotal = null;
|
|
725
|
+
let currency = null;
|
|
726
|
+
if (lineItems.length > 0) {
|
|
727
|
+
amountTotal = lineItems.reduce(
|
|
728
|
+
(sum, item) => sum + (item.priceData?.unitAmount ?? 0) * (item.quantity ?? 1),
|
|
729
|
+
0
|
|
730
|
+
);
|
|
731
|
+
currency = lineItems[0].priceData?.currency ?? null;
|
|
732
|
+
}
|
|
733
|
+
state.sessionStatuses.set(id, {
|
|
734
|
+
id,
|
|
735
|
+
status: "open",
|
|
736
|
+
paymentStatus: "unpaid",
|
|
737
|
+
mode: body.mode ?? "payment",
|
|
738
|
+
amountTotal,
|
|
739
|
+
currency,
|
|
740
|
+
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
741
|
+
metadata: body.metadata ?? {},
|
|
742
|
+
expiresAt: Math.floor(Date.now() / 1e3) + 3600
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function seedBeamLink(id, body, merchantId) {
|
|
746
|
+
const order = body.order;
|
|
747
|
+
state.paymentLinks.set(id, {
|
|
748
|
+
paymentLinkId: id,
|
|
749
|
+
merchantId,
|
|
750
|
+
url: `https://beam.test/pay/${id}`,
|
|
751
|
+
status: "ACTIVE",
|
|
752
|
+
order: {
|
|
753
|
+
netAmount: Number(order?.netAmount ?? 0),
|
|
754
|
+
currency: String(order?.currency ?? "THB"),
|
|
755
|
+
description: String(order?.description ?? ""),
|
|
756
|
+
referenceId: order?.referenceId ? String(order.referenceId) : void 0,
|
|
757
|
+
internalNote: order?.internalNote ? String(order.internalNote) : void 0,
|
|
758
|
+
orderItems: order?.orderItems
|
|
759
|
+
},
|
|
760
|
+
redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
|
|
761
|
+
linkSettings: body.linkSettings,
|
|
762
|
+
collectDeliveryAddress: body.collectDeliveryAddress === true
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
async function handlePaymentsRequest(request, url) {
|
|
766
|
+
const pathname = url.pathname;
|
|
767
|
+
if (/\/bolt-connections/.test(pathname) || /\/bolt-intents/.test(pathname) || /\/charges(\/|$)/.test(pathname) || /\/billing-portal$/.test(pathname)) {
|
|
768
|
+
return payErr("Not found", 404, "NOT_FOUND");
|
|
769
|
+
}
|
|
770
|
+
const beamProductsMatch = pathname.match(
|
|
771
|
+
/^\/api\/store\/beam\/([^/]+)\/payment-links(?:\/([^/]+))?$/
|
|
772
|
+
);
|
|
773
|
+
if (beamProductsMatch) {
|
|
774
|
+
const merchantId = beamProductsMatch[1];
|
|
775
|
+
const linkId = beamProductsMatch[2];
|
|
776
|
+
if (!linkId && request.method === "POST") {
|
|
777
|
+
const body = await readJsonBody(request);
|
|
778
|
+
const id = nextPaymentLinkId();
|
|
779
|
+
const checkoutUrl = `https://beam.test/pay/${id}`;
|
|
780
|
+
const captured = {
|
|
781
|
+
id,
|
|
782
|
+
url: checkoutUrl,
|
|
783
|
+
provider: "beam",
|
|
784
|
+
options: body,
|
|
785
|
+
metadata: body.metadata,
|
|
786
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
787
|
+
};
|
|
788
|
+
state.checkouts.push(captured);
|
|
789
|
+
seedBeamLink(id, body, merchantId);
|
|
790
|
+
return json({ id, url: checkoutUrl });
|
|
791
|
+
}
|
|
792
|
+
if (linkId && request.method === "GET") {
|
|
793
|
+
const link = state.paymentLinks.get(linkId);
|
|
794
|
+
if (!link) return payErr("Payment link not found", 404, "NOT_FOUND");
|
|
795
|
+
return json({ paymentLink: link });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const stripeStoreMatch = pathname.match(/^\/api\/store\/([^/]+)\/(.+)$/);
|
|
799
|
+
if (stripeStoreMatch) {
|
|
800
|
+
const accountId = stripeStoreMatch[1];
|
|
801
|
+
const subPath = stripeStoreMatch[2];
|
|
802
|
+
if (accountId === "beam") {
|
|
803
|
+
return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
|
|
804
|
+
}
|
|
805
|
+
if (subPath === "products" && request.method === "GET") {
|
|
806
|
+
return json({ products: state.products });
|
|
807
|
+
}
|
|
808
|
+
const productMatch = subPath.match(/^products\/([^/]+)$/);
|
|
809
|
+
if (productMatch && request.method === "GET") {
|
|
810
|
+
const product = state.products.find((p) => p.id === productMatch[1]);
|
|
811
|
+
if (!product) return payErr("Product not found", 404, "NOT_FOUND");
|
|
812
|
+
return json({ product });
|
|
813
|
+
}
|
|
814
|
+
if (subPath === "checkout" && request.method === "POST") {
|
|
815
|
+
const body = await readJsonBody(request);
|
|
816
|
+
const id = nextCheckoutId();
|
|
817
|
+
const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
|
|
818
|
+
const captured = {
|
|
819
|
+
id,
|
|
820
|
+
url: checkoutUrl,
|
|
821
|
+
provider: "stripe",
|
|
822
|
+
options: body,
|
|
823
|
+
mode: body.mode,
|
|
824
|
+
metadata: body.metadata,
|
|
825
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
826
|
+
};
|
|
827
|
+
state.checkouts.push(captured);
|
|
828
|
+
seedStripeSession(id, body);
|
|
829
|
+
return json({ id, url: checkoutUrl });
|
|
830
|
+
}
|
|
831
|
+
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
832
|
+
if (sessionMatch && request.method === "GET") {
|
|
833
|
+
const session = state.sessionStatuses.get(sessionMatch[1]);
|
|
834
|
+
if (!session) return payErr("Checkout session not found", 404, "NOT_FOUND");
|
|
835
|
+
return json({ session });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/simulator/storage.ts
|
|
842
|
+
import crypto6 from "crypto";
|
|
843
|
+
function storageErr(error, status = 400) {
|
|
844
|
+
return json({ error }, status);
|
|
845
|
+
}
|
|
846
|
+
function fileExtension(filename) {
|
|
847
|
+
const dot = filename.lastIndexOf(".");
|
|
848
|
+
return dot === -1 ? "bin" : filename.slice(dot + 1);
|
|
849
|
+
}
|
|
850
|
+
function buildKey(fileId, filename) {
|
|
851
|
+
const orgId = process.env.ORGANIZATION_ID ?? TEST_ENV_DEFAULTS.ORGANIZATION_ID;
|
|
852
|
+
const projectId = process.env.PROJECT_ID ?? TEST_ENV_DEFAULTS.PROJECT_ID;
|
|
853
|
+
const deploymentId = process.env.DEPLOYMENT_ID ?? TEST_ENV_DEFAULTS.DEPLOYMENT_ID;
|
|
854
|
+
return `${orgId}/${projectId}/${deploymentId}/${fileId}.${fileExtension(filename)}`;
|
|
855
|
+
}
|
|
856
|
+
function buildUrl(key) {
|
|
857
|
+
return `${STORAGE_TEST_URL}/files/${key}`;
|
|
858
|
+
}
|
|
859
|
+
function toUploadResponse(record) {
|
|
860
|
+
return {
|
|
861
|
+
id: record.id,
|
|
862
|
+
key: record.key,
|
|
863
|
+
filename: record.filename,
|
|
864
|
+
contentType: record.contentType,
|
|
865
|
+
sizeBytes: record.sizeBytes,
|
|
866
|
+
url: record.url,
|
|
867
|
+
uploadedAt: record.uploadedAt,
|
|
868
|
+
isPublic: record.isPublic,
|
|
869
|
+
metadata: record.metadata
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function captureUpload(record, method, path) {
|
|
873
|
+
const captured = {
|
|
874
|
+
id: record.id,
|
|
875
|
+
key: record.key,
|
|
876
|
+
filename: record.filename,
|
|
877
|
+
contentType: record.contentType,
|
|
878
|
+
sizeBytes: record.sizeBytes,
|
|
879
|
+
url: record.url,
|
|
880
|
+
uploadedAt: record.uploadedAt,
|
|
881
|
+
isPublic: record.isPublic,
|
|
882
|
+
metadata: record.metadata,
|
|
883
|
+
method,
|
|
884
|
+
path
|
|
885
|
+
};
|
|
886
|
+
state.uploads.push(captured);
|
|
887
|
+
}
|
|
888
|
+
function createFileRecord(input) {
|
|
889
|
+
state.uploadCounter += 1;
|
|
890
|
+
const id = `file_test_${state.uploadCounter}`;
|
|
891
|
+
const key = buildKey(id, input.filename);
|
|
892
|
+
return {
|
|
893
|
+
id,
|
|
894
|
+
key,
|
|
895
|
+
filename: input.filename,
|
|
896
|
+
contentType: input.contentType,
|
|
897
|
+
sizeBytes: input.sizeBytes,
|
|
898
|
+
url: buildUrl(key),
|
|
899
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
900
|
+
metadata: input.metadata,
|
|
901
|
+
isPublic: input.isPublic,
|
|
902
|
+
deleted: false
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function activeFiles() {
|
|
906
|
+
return [...state.storageFiles.values()].filter((f) => !f.deleted);
|
|
907
|
+
}
|
|
908
|
+
async function handleStorageRequest(request, url) {
|
|
909
|
+
const pathname = url.pathname;
|
|
910
|
+
if (pathname.startsWith("/upload/presign/multipart")) {
|
|
911
|
+
return storageErr("Not found", 404);
|
|
912
|
+
}
|
|
913
|
+
const presignedPutMatch = pathname.match(/^\/presigned\/([^/]+)$/);
|
|
914
|
+
if (presignedPutMatch && request.method === "PUT") {
|
|
915
|
+
const fileId = presignedPutMatch[1];
|
|
916
|
+
if (!state.presignPending.has(fileId)) {
|
|
917
|
+
return storageErr("Presign session not found", 404);
|
|
918
|
+
}
|
|
919
|
+
return new Response(null, {
|
|
920
|
+
status: 200,
|
|
921
|
+
headers: { etag: `"${crypto6.randomUUID()}"` }
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
if (pathname === "/upload/presign/complete" && request.method === "POST") {
|
|
925
|
+
const body = await readJsonBody(request);
|
|
926
|
+
const fileId = String(body.fileId ?? "");
|
|
927
|
+
const pending = state.presignPending.get(fileId);
|
|
928
|
+
if (!pending) return storageErr("Presign session not found", 404);
|
|
929
|
+
const record = {
|
|
930
|
+
id: fileId,
|
|
931
|
+
key: pending.key,
|
|
932
|
+
filename: pending.fileName,
|
|
933
|
+
contentType: pending.contentType,
|
|
934
|
+
sizeBytes: pending.sizeBytes,
|
|
935
|
+
url: buildUrl(pending.key),
|
|
936
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
937
|
+
metadata: pending.metadata,
|
|
938
|
+
isPublic: pending.isPublic,
|
|
939
|
+
deleted: false
|
|
940
|
+
};
|
|
941
|
+
state.storageFiles.set(record.id, record);
|
|
942
|
+
state.presignPending.delete(fileId);
|
|
943
|
+
captureUpload(record, "POST", "/upload/presign/complete");
|
|
944
|
+
return json(toUploadResponse(record));
|
|
945
|
+
}
|
|
946
|
+
if (pathname === "/upload/presign" && request.method === "POST") {
|
|
947
|
+
const body = await readJsonBody(request);
|
|
948
|
+
const fileName = String(body.fileName ?? "upload.bin");
|
|
949
|
+
const contentType = String(body.contentType ?? "application/octet-stream");
|
|
950
|
+
const sizeBytes = Number(body.sizeBytes ?? 0);
|
|
951
|
+
const isPublic = body.isPublic !== false;
|
|
952
|
+
const metadata = body.metadata;
|
|
953
|
+
state.uploadCounter += 1;
|
|
954
|
+
const fileId = `file_test_${state.uploadCounter}`;
|
|
955
|
+
const key = buildKey(fileId, fileName);
|
|
956
|
+
state.presignPending.set(fileId, {
|
|
957
|
+
fileName,
|
|
958
|
+
contentType,
|
|
959
|
+
sizeBytes,
|
|
960
|
+
isPublic,
|
|
961
|
+
metadata,
|
|
962
|
+
key
|
|
963
|
+
});
|
|
964
|
+
return json({
|
|
965
|
+
fileId,
|
|
966
|
+
key,
|
|
967
|
+
presignedUrl: `${STORAGE_TEST_URL}/presigned/${fileId}`,
|
|
968
|
+
contentType,
|
|
969
|
+
expiresIn: 3600
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
if (pathname === "/upload" && request.method === "POST") {
|
|
973
|
+
const isPublic = url.searchParams.get("isPublic") !== "false";
|
|
974
|
+
const formData = await request.formData();
|
|
975
|
+
const file = formData.get("file");
|
|
976
|
+
if (!(file instanceof Blob)) {
|
|
977
|
+
return storageErr("file is required");
|
|
978
|
+
}
|
|
979
|
+
const filename = formData.get("filename") ?? (file instanceof File ? file.name : "upload.bin");
|
|
980
|
+
const metadataRaw = formData.get("metadata");
|
|
981
|
+
let metadata;
|
|
982
|
+
if (typeof metadataRaw === "string" && metadataRaw) {
|
|
983
|
+
try {
|
|
984
|
+
metadata = JSON.parse(metadataRaw);
|
|
985
|
+
} catch {
|
|
986
|
+
return storageErr("metadata must be valid JSON");
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const record = createFileRecord({
|
|
990
|
+
filename,
|
|
991
|
+
contentType: file.type || "application/octet-stream",
|
|
992
|
+
sizeBytes: file.size,
|
|
993
|
+
isPublic,
|
|
994
|
+
metadata
|
|
995
|
+
});
|
|
996
|
+
state.storageFiles.set(record.id, record);
|
|
997
|
+
captureUpload(record, "POST", "/upload");
|
|
998
|
+
return json(toUploadResponse(record));
|
|
999
|
+
}
|
|
1000
|
+
const fileMatch = pathname.match(/^\/api\/files\/([^/]+)$/);
|
|
1001
|
+
if (fileMatch) {
|
|
1002
|
+
const fileId = fileMatch[1];
|
|
1003
|
+
const record = state.storageFiles.get(fileId);
|
|
1004
|
+
if (!record || record.deleted) {
|
|
1005
|
+
return storageErr("File not found", 404);
|
|
1006
|
+
}
|
|
1007
|
+
if (request.method === "GET") {
|
|
1008
|
+
return json(toUploadResponse(record));
|
|
1009
|
+
}
|
|
1010
|
+
if (request.method === "PATCH") {
|
|
1011
|
+
const body = await readJsonBody(request);
|
|
1012
|
+
if (body.fileName !== void 0) record.filename = String(body.fileName);
|
|
1013
|
+
if (body.metadata !== void 0) {
|
|
1014
|
+
record.metadata = body.metadata;
|
|
1015
|
+
}
|
|
1016
|
+
if (body.isPublic !== void 0) record.isPublic = body.isPublic === true;
|
|
1017
|
+
return json(toUploadResponse(record));
|
|
1018
|
+
}
|
|
1019
|
+
if (request.method === "DELETE") {
|
|
1020
|
+
record.deleted = true;
|
|
1021
|
+
return json({ success: true });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (pathname === "/api/files" && request.method === "GET") {
|
|
1025
|
+
const parseParam = (raw, fallback, min) => {
|
|
1026
|
+
const n = Number(raw);
|
|
1027
|
+
return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
|
|
1028
|
+
};
|
|
1029
|
+
const limit = parseParam(url.searchParams.get("limit"), 50, 1);
|
|
1030
|
+
const offset = parseParam(url.searchParams.get("offset"), 0, 0);
|
|
1031
|
+
const prefix = url.searchParams.get("prefix") ?? "";
|
|
1032
|
+
let files = activeFiles();
|
|
1033
|
+
if (prefix) {
|
|
1034
|
+
files = files.filter((f) => f.key.startsWith(prefix) || f.filename.startsWith(prefix));
|
|
1035
|
+
}
|
|
1036
|
+
const total = files.length;
|
|
1037
|
+
const slice = files.slice(offset, offset + limit);
|
|
1038
|
+
return json({
|
|
1039
|
+
files: slice.map((f) => toUploadResponse(f)),
|
|
1040
|
+
total,
|
|
1041
|
+
limit,
|
|
1042
|
+
offset,
|
|
1043
|
+
hasMore: offset + slice.length < total
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
return storageErr(`No storage simulator for ${request.method} ${pathname}`, 404);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/simulator/messaging.ts
|
|
1050
|
+
import crypto7 from "crypto";
|
|
1051
|
+
function captureMessage(channel, recipient, body, connectionId) {
|
|
1052
|
+
const message = {
|
|
1053
|
+
channel,
|
|
1054
|
+
recipient,
|
|
1055
|
+
body: {
|
|
1056
|
+
text: body.text ? String(body.text) : void 0,
|
|
1057
|
+
blocks: body.blocks,
|
|
1058
|
+
threadTs: body.threadTs ? String(body.threadTs) : void 0,
|
|
1059
|
+
messagingType: body.messagingType ? String(body.messagingType) : void 0,
|
|
1060
|
+
tag: body.tag ? String(body.tag) : void 0
|
|
1061
|
+
},
|
|
1062
|
+
connectionId,
|
|
1063
|
+
sentAt: /* @__PURE__ */ new Date()
|
|
1064
|
+
};
|
|
1065
|
+
state.messages.push(message);
|
|
1066
|
+
}
|
|
1067
|
+
async function handleMessagingRequest(request, channel, subPath) {
|
|
1068
|
+
const method = request.method;
|
|
1069
|
+
if ((subPath === "" || subPath === "/" || subPath === "/connections") && method === "GET") {
|
|
1070
|
+
return success({ connections: [] });
|
|
1071
|
+
}
|
|
1072
|
+
if (channel === "slack" && subPath === "/send" && method === "POST") {
|
|
1073
|
+
const body = await readJsonBody(request);
|
|
1074
|
+
if (!body.text && !body.blocks) {
|
|
1075
|
+
return failure("text or blocks is required");
|
|
1076
|
+
}
|
|
1077
|
+
const slackChannel = String(body.channel ?? "");
|
|
1078
|
+
if (!slackChannel) {
|
|
1079
|
+
return failure("channel is required");
|
|
1080
|
+
}
|
|
1081
|
+
captureMessage(
|
|
1082
|
+
"slack",
|
|
1083
|
+
slackChannel,
|
|
1084
|
+
body,
|
|
1085
|
+
body.connectionId ? String(body.connectionId) : void 0
|
|
1086
|
+
);
|
|
1087
|
+
return success({
|
|
1088
|
+
ok: true,
|
|
1089
|
+
ts: `${Date.now()}.${crypto7.randomUUID().slice(0, 6)}`,
|
|
1090
|
+
channel: slackChannel
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
if (channel === "line" && subPath === "/push" && method === "POST") {
|
|
1094
|
+
const body = await readJsonBody(request);
|
|
1095
|
+
const userId = String(body.userId ?? "");
|
|
1096
|
+
const message = body.message;
|
|
1097
|
+
if (!userId) return failure("userId is required");
|
|
1098
|
+
if (!message || message.type !== "text" || !message.text) {
|
|
1099
|
+
return failure("message must be { type: 'text', text: string }");
|
|
1100
|
+
}
|
|
1101
|
+
if (message.text.length > 5e3) {
|
|
1102
|
+
return failure("message text exceeds maximum length");
|
|
1103
|
+
}
|
|
1104
|
+
captureMessage(
|
|
1105
|
+
"line",
|
|
1106
|
+
userId,
|
|
1107
|
+
{ text: message.text },
|
|
1108
|
+
body.connectionId ? String(body.connectionId) : void 0
|
|
1109
|
+
);
|
|
1110
|
+
return success({ success: true });
|
|
1111
|
+
}
|
|
1112
|
+
if (channel === "facebook" && subPath === "/send" && method === "POST") {
|
|
1113
|
+
const body = await readJsonBody(request);
|
|
1114
|
+
const recipientId = String(body.recipientId ?? "");
|
|
1115
|
+
const message = body.message;
|
|
1116
|
+
if (!recipientId) return failure("recipientId is required");
|
|
1117
|
+
if (!message || message.type !== "text" || !message.text) {
|
|
1118
|
+
return failure("message must be { type: 'text', text: string }");
|
|
1119
|
+
}
|
|
1120
|
+
if (message.text.length > 2e3) {
|
|
1121
|
+
return failure("message text exceeds maximum length");
|
|
1122
|
+
}
|
|
1123
|
+
captureMessage(
|
|
1124
|
+
"facebook",
|
|
1125
|
+
recipientId,
|
|
1126
|
+
{
|
|
1127
|
+
text: message.text,
|
|
1128
|
+
messagingType: body.messagingType ? String(body.messagingType) : void 0,
|
|
1129
|
+
tag: body.tag ? String(body.tag) : void 0
|
|
1130
|
+
},
|
|
1131
|
+
body.connectionId ? String(body.connectionId) : void 0
|
|
1132
|
+
);
|
|
1133
|
+
return success({ success: true });
|
|
1134
|
+
}
|
|
1135
|
+
return failure(`No messaging simulator for ${method} .../integrations/${channel}${subPath}`, 404);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
684
1138
|
// src/simulator/router.ts
|
|
685
1139
|
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
686
1140
|
originalFetch: null
|
|
@@ -688,6 +1142,21 @@ var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
|
688
1142
|
function isLocalHost(hostname) {
|
|
689
1143
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
690
1144
|
}
|
|
1145
|
+
function requiresDeploymentHmac(request, url) {
|
|
1146
|
+
const isStorageHost = url.hostname === STORAGE_TEST_HOST;
|
|
1147
|
+
const isPresignedPut = isStorageHost && request.method === "PUT" && /^\/presigned\/[^/]+$/.test(url.pathname);
|
|
1148
|
+
if (isStorageHost) {
|
|
1149
|
+
return !isPresignedPut;
|
|
1150
|
+
}
|
|
1151
|
+
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
1152
|
+
const isEmail = url.pathname === "/api/email/send";
|
|
1153
|
+
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
1154
|
+
const storeMatch = url.pathname.match(/^\/api\/store\//);
|
|
1155
|
+
const messagingMatch = url.pathname.match(
|
|
1156
|
+
/^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
|
|
1157
|
+
);
|
|
1158
|
+
return !!(dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch);
|
|
1159
|
+
}
|
|
691
1160
|
async function handleSimulatedRequest(request, url) {
|
|
692
1161
|
if (url.pathname === "/sql") {
|
|
693
1162
|
return handleNeonSql(requireDb(), request);
|
|
@@ -699,7 +1168,12 @@ async function handleSimulatedRequest(request, url) {
|
|
|
699
1168
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
700
1169
|
const isEmail = url.pathname === "/api/email/send";
|
|
701
1170
|
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
702
|
-
|
|
1171
|
+
const storeMatch = url.pathname.match(/^\/api\/store\//);
|
|
1172
|
+
const messagingMatch = url.pathname.match(
|
|
1173
|
+
/^\/api\/deployments\/[^/]+\/integrations\/(slack|line|facebook)(\/.*)?$/
|
|
1174
|
+
);
|
|
1175
|
+
const isStorageHost = url.hostname === STORAGE_TEST_HOST;
|
|
1176
|
+
if (requiresDeploymentHmac(request, url)) {
|
|
703
1177
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
704
1178
|
if (!authHeader) {
|
|
705
1179
|
return failure("Missing authentication header", 401);
|
|
@@ -709,34 +1183,44 @@ async function handleSimulatedRequest(request, url) {
|
|
|
709
1183
|
return failure("Invalid authentication", 401);
|
|
710
1184
|
}
|
|
711
1185
|
}
|
|
1186
|
+
if (isStorageHost) {
|
|
1187
|
+
return handleStorageRequest(request, url);
|
|
1188
|
+
}
|
|
712
1189
|
if (isEmail && request.method === "POST") {
|
|
713
1190
|
return handleEmailSend(request);
|
|
714
1191
|
}
|
|
715
1192
|
if (identitiesMatch) {
|
|
716
1193
|
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
717
1194
|
}
|
|
1195
|
+
if (storeMatch) {
|
|
1196
|
+
return handlePaymentsRequest(request, url);
|
|
1197
|
+
}
|
|
1198
|
+
if (messagingMatch) {
|
|
1199
|
+
const channel = messagingMatch[1];
|
|
1200
|
+
return handleMessagingRequest(request, channel, messagingMatch[2] ?? "");
|
|
1201
|
+
}
|
|
718
1202
|
if (dataStoreMatch) {
|
|
719
1203
|
const subPath = dataStoreMatch[1] ?? "";
|
|
720
1204
|
const db = requireDb();
|
|
721
|
-
const
|
|
1205
|
+
const readBody = async () => await request.json();
|
|
722
1206
|
if (subPath === "/query" && request.method === "POST") {
|
|
723
|
-
return handleQuery(db, await
|
|
1207
|
+
return handleQuery(db, await readBody());
|
|
724
1208
|
}
|
|
725
1209
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
726
|
-
return handleMutate(db, await
|
|
1210
|
+
return handleMutate(db, await readBody());
|
|
727
1211
|
}
|
|
728
1212
|
if (subPath === "/schema" && request.method === "GET") {
|
|
729
1213
|
return handleGetSchema(db);
|
|
730
1214
|
}
|
|
731
1215
|
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
732
|
-
return handleCreateTable(db, await
|
|
1216
|
+
return handleCreateTable(db, await readBody());
|
|
733
1217
|
}
|
|
734
1218
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
735
|
-
return handleAddColumn(db, await
|
|
1219
|
+
return handleAddColumn(db, await readBody());
|
|
736
1220
|
}
|
|
737
1221
|
}
|
|
738
1222
|
return failure(
|
|
739
|
-
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
|
|
1223
|
+
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, auth verify/refresh, Neon /sql.`,
|
|
740
1224
|
404
|
|
741
1225
|
);
|
|
742
1226
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -62,11 +62,12 @@
|
|
|
62
62
|
"author": "Stardeck",
|
|
63
63
|
"license": "MIT",
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@electric-sql/pglite": "^0.3.0"
|
|
66
|
-
"@stardeck-customer-apps/core": "*"
|
|
65
|
+
"@electric-sql/pglite": "^0.3.0"
|
|
67
66
|
},
|
|
68
67
|
"peerDependencies": {
|
|
69
68
|
"@stardeck-customer-apps/integrations-sdk": ">=1.6.0",
|
|
69
|
+
"@stardeck-customer-apps/payments-sdk": ">=0.1.0",
|
|
70
|
+
"@stardeck-customer-apps/storage-sdk": ">=0.1.0",
|
|
70
71
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
71
72
|
"vitest": ">=2.0.0"
|
|
72
73
|
},
|
|
@@ -74,6 +75,12 @@
|
|
|
74
75
|
"@stardeck-customer-apps/integrations-sdk": {
|
|
75
76
|
"optional": true
|
|
76
77
|
},
|
|
78
|
+
"@stardeck-customer-apps/payments-sdk": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"@stardeck-customer-apps/storage-sdk": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
77
84
|
"next": {
|
|
78
85
|
"optional": true
|
|
79
86
|
}
|
|
@@ -84,6 +91,8 @@
|
|
|
84
91
|
"@stardeck-customer-apps/data-store-sdk": "*",
|
|
85
92
|
"@stardeck-customer-apps/email-sdk": "*",
|
|
86
93
|
"@stardeck-customer-apps/integrations-sdk": "*",
|
|
94
|
+
"@stardeck-customer-apps/payments-sdk": "*",
|
|
95
|
+
"@stardeck-customer-apps/storage-sdk": "*",
|
|
87
96
|
"@stardeck-customer-apps/tsconfig": "*",
|
|
88
97
|
"@types/node": "^24.10.1",
|
|
89
98
|
"kysely": "^0.27.0",
|