mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #985 from PR0M3TH3AN/codex/implement-zap-error-handling-and-tests
Handle partial zap receipt failures
This commit is contained in:
3 files changed
+288
No files matched your search
@@ -2965,6 +2965,79 @@ async function handleZapSend(event) {
|
||||
|
||||
const { context, result } = attempt;
|
||||
const receipts = Array.isArray(result?.receipts) ? result.receipts : [];
|
||||
|
||||
const failureReceipts = receipts.filter((receipt) => {
|
||||
if (!receipt || typeof receipt !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (typeof receipt.status === "string" && receipt.status !== "success") {
|
||||
return true;
|
||||
}
|
||||
return Boolean(receipt.error);
|
||||
});
|
||||
|
||||
if (failureReceipts.length) {
|
||||
renderZapReceipts(receipts, { partial: true });
|
||||
|
||||
const failureShares = failureReceipts
|
||||
.map((receipt) => {
|
||||
const amount = Math.max(0, Math.round(Number(receipt.amount) || 0));
|
||||
return {
|
||||
type: receipt.recipientType || receipt.type || "creator",
|
||||
amount,
|
||||
address:
|
||||
typeof receipt.address === "string" && receipt.address
|
||||
? receipt.address
|
||||
: undefined,
|
||||
error: receipt.error
|
||||
};
|
||||
})
|
||||
.filter((share) => share.amount > 0);
|
||||
|
||||
if (failureShares.length) {
|
||||
markZapRetryPending(failureShares);
|
||||
}
|
||||
|
||||
const shareSummary = failureShares
|
||||
.map((share) => `${describeShareType(share.type)} ${share.amount} sats`)
|
||||
.join(", ");
|
||||
|
||||
const failureMessage = failureReceipts
|
||||
.map((receipt) => {
|
||||
if (typeof receipt?.error?.message === "string") {
|
||||
return receipt.error.message;
|
||||
}
|
||||
if (typeof receipt?.error === "string") {
|
||||
return receipt.error;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.find((message) => message);
|
||||
|
||||
const isBudgetExceeded =
|
||||
typeof failureMessage === "string" && /budget exceeded/i.test(failureMessage);
|
||||
|
||||
let warningMessage;
|
||||
if (isBudgetExceeded) {
|
||||
warningMessage =
|
||||
"Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then retry the remaining shares.";
|
||||
} else if (failureMessage) {
|
||||
warningMessage = failureMessage;
|
||||
} else {
|
||||
warningMessage =
|
||||
"Some zap shares failed. Press Send again to retry the remaining shares.";
|
||||
}
|
||||
|
||||
if (shareSummary) {
|
||||
const normalized = warningMessage.replace(/\.*$/, "");
|
||||
warningMessage = `${normalized}. Remaining share(s): ${shareSummary}.`;
|
||||
}
|
||||
|
||||
setZapStatus(warningMessage, "warning");
|
||||
app?.showError?.(warningMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
renderZapReceipts(receipts, { partial: false });
|
||||
|
||||
const creatorShare = context.shares.creatorShare;
|
||||
|
||||
@@ -246,6 +246,88 @@ export default class ZapController {
|
||||
|
||||
const { context, result } = attempt;
|
||||
const receipts = Array.isArray(result?.receipts) ? result.receipts : [];
|
||||
|
||||
const failureReceipts = receipts.filter((receipt) => {
|
||||
if (!receipt || typeof receipt !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (typeof receipt.status === "string" && receipt.status !== "success") {
|
||||
return true;
|
||||
}
|
||||
return Boolean(receipt.error);
|
||||
});
|
||||
|
||||
if (failureReceipts.length) {
|
||||
this.videoModal?.renderZapReceipts(receipts, { partial: true });
|
||||
|
||||
const failureShares = failureReceipts
|
||||
.map((receipt) => {
|
||||
const amount = Math.max(0, Math.round(Number(receipt.amount) || 0));
|
||||
return {
|
||||
type: receipt.recipientType || receipt.type || "creator",
|
||||
amount,
|
||||
address:
|
||||
typeof receipt.address === "string" && receipt.address
|
||||
? receipt.address
|
||||
: undefined,
|
||||
error: receipt.error,
|
||||
};
|
||||
})
|
||||
.filter((share) => share.amount > 0);
|
||||
|
||||
if (failureShares.length) {
|
||||
this.markRetryPending(failureShares, trimmedComment);
|
||||
}
|
||||
|
||||
const shareSummary = failureShares
|
||||
.map((share) => {
|
||||
const label =
|
||||
share.type === "platform"
|
||||
? "Platform"
|
||||
: share.type === "creator"
|
||||
? "Creator"
|
||||
: "Lightning";
|
||||
return `${label} ${share.amount} sats`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
const failureMessage = failureReceipts
|
||||
.map((receipt) => {
|
||||
if (typeof receipt?.error?.message === "string") {
|
||||
return receipt.error.message;
|
||||
}
|
||||
if (typeof receipt?.error === "string") {
|
||||
return receipt.error;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.find((message) => message);
|
||||
|
||||
const isBudgetExceeded =
|
||||
typeof failureMessage === "string" &&
|
||||
/budget exceeded/i.test(failureMessage);
|
||||
|
||||
let warningMessage;
|
||||
if (isBudgetExceeded) {
|
||||
warningMessage =
|
||||
"Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then retry the remaining shares.";
|
||||
} else if (failureMessage) {
|
||||
warningMessage = failureMessage;
|
||||
} else {
|
||||
warningMessage =
|
||||
"Some zap shares failed. Press Send again to retry the remaining shares.";
|
||||
}
|
||||
|
||||
if (shareSummary) {
|
||||
const normalized = warningMessage.replace(/\.*$/, "");
|
||||
warningMessage = `${normalized}. Remaining share(s): ${shareSummary}.`;
|
||||
}
|
||||
|
||||
this.videoModal?.setZapStatus(warningMessage, "warning");
|
||||
this.notifyError(warningMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoModal?.renderZapReceipts(receipts, { partial: false });
|
||||
|
||||
const creatorShare = context.shares.creatorShare;
|
||||
|
||||
@@ -303,6 +303,23 @@ async function createApp({ splitAndZap }) {
|
||||
},
|
||||
});
|
||||
|
||||
app.boundVideoModalZapOpenHandler = (event = {}) => {
|
||||
const requiresLogin = Boolean(event?.detail?.requiresLogin);
|
||||
app.pendingModalZapOpen = requiresLogin;
|
||||
|
||||
const openResult = app.zapController?.open({ requiresLogin });
|
||||
if (!openResult) {
|
||||
event?.preventDefault?.();
|
||||
if (!requiresLogin) {
|
||||
app.pendingModalZapOpen = false;
|
||||
}
|
||||
modalStub.closeZapDialog?.({ silent: true, restoreFocus: false });
|
||||
return;
|
||||
}
|
||||
|
||||
app.pendingModalZapOpen = false;
|
||||
};
|
||||
|
||||
const creatorAddress = "[email protected]";
|
||||
const platformAddress = "[email protected]";
|
||||
setCachedPlatformLightningAddress(platformAddress);
|
||||
@@ -420,6 +437,9 @@ await (async () => {
|
||||
nostrClient.sessionActor = { pubkey: sessionPubkey, privateKey: "priv" };
|
||||
|
||||
app.pubkey = sessionPubkey;
|
||||
app.isUserLoggedIn = () => false;
|
||||
app.zapController.isUserLoggedIn = () => false;
|
||||
app.zapController.hasSessionActor = () => true;
|
||||
|
||||
modalStub.zapVisibilityCalls = [];
|
||||
app.zapController.setVisibility(true);
|
||||
@@ -543,6 +563,119 @@ await (async () => {
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
// Test: platform receipt failure keeps retry state active and surfaces warning
|
||||
const platformError = new Error("Budget exceeded");
|
||||
const receipts = [
|
||||
{
|
||||
recipientType: "creator",
|
||||
status: "success",
|
||||
amount: 900,
|
||||
address: "[email protected]",
|
||||
payment: { result: { preimage: "cc".repeat(16) } },
|
||||
},
|
||||
{
|
||||
recipientType: "platform",
|
||||
status: "error",
|
||||
amount: 100,
|
||||
address: "[email protected]",
|
||||
error: platformError,
|
||||
},
|
||||
];
|
||||
|
||||
const { app, modalStub, creatorAddress, platformAddress } = await createApp({
|
||||
splitAndZap: async () => ({ receipts }),
|
||||
});
|
||||
|
||||
const pubkeyHex = "c".repeat(64);
|
||||
app.pubkey = pubkeyHex;
|
||||
const normalized = app.normalizeHexPubkey(pubkeyHex);
|
||||
app.nwcSettingsService.cache.set(normalized, {
|
||||
nwcUri: "nostr+walletconnect://example",
|
||||
defaultZap: null,
|
||||
lastChecked: null,
|
||||
version: "",
|
||||
});
|
||||
|
||||
app.currentVideo = {
|
||||
id: "event789",
|
||||
pubkey: pubkeyHex,
|
||||
tags: [["d", "video789"]],
|
||||
content: "",
|
||||
created_at: 1_700_000_100,
|
||||
lightningAddress: creatorAddress,
|
||||
};
|
||||
|
||||
const initialResetCount = modalStub.resetForms.length;
|
||||
const initialCompletedCount = modalStub.completedStates.length;
|
||||
|
||||
await app.zapController.sendZap({ amount: 1000, comment: "Nice work" });
|
||||
|
||||
const lastReceiptEntry = modalStub.receipts[modalStub.receipts.length - 1];
|
||||
assert.equal(
|
||||
lastReceiptEntry.options?.partial,
|
||||
true,
|
||||
"should render zap receipts in partial mode when failures occur",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
app.zapController.modalZapRetryState?.shares?.length,
|
||||
1,
|
||||
"controller should queue failing receipt for retry",
|
||||
);
|
||||
assert.equal(
|
||||
modalStub.retryState?.pending,
|
||||
true,
|
||||
"modal should display retry pending state for failed share",
|
||||
);
|
||||
|
||||
const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1];
|
||||
assert.equal(lastStatus.tone, "warning", "warning tone should be emitted for receipt failure");
|
||||
assert.ok(
|
||||
/increase your wallet zap limit or reduce the platform fee/i.test(lastStatus.message),
|
||||
"status message should translate budget exceeded guidance",
|
||||
);
|
||||
assert.ok(
|
||||
/Remaining share\(s\): Platform 100 sats\./.test(lastStatus.message),
|
||||
"status message should summarize remaining platform share",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
modalStub.resetForms.length,
|
||||
initialResetCount,
|
||||
"zap form should not reset while a share remains unpaid",
|
||||
);
|
||||
assert.equal(
|
||||
modalStub.completedStates.length,
|
||||
initialCompletedCount + 1,
|
||||
"zap completion calls should only reflect the in-flight attempt",
|
||||
);
|
||||
assert.equal(
|
||||
modalStub.completedStates[modalStub.completedStates.length - 1],
|
||||
false,
|
||||
"zap modal should not mark zap as completed on partial failure",
|
||||
);
|
||||
assert.equal(
|
||||
app.zapController.modalZapCommentValue,
|
||||
"Nice work",
|
||||
"zap controller should retain comment value for retry",
|
||||
);
|
||||
|
||||
// Ensure platform retry summary references the expected address context
|
||||
assert.equal(
|
||||
app.zapController.modalZapRetryState?.shares?.[0]?.type,
|
||||
"platform",
|
||||
"retry state should track platform share type",
|
||||
);
|
||||
assert.equal(
|
||||
app.zapController.modalZapRetryState?.shares?.[0]?.amount,
|
||||
100,
|
||||
"retry state should preserve remaining platform amount",
|
||||
);
|
||||
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
// Test: allowance exhaustion surfaces guidance and skips retry queue
|
||||
const { app, modalStub, creatorAddress, platformAddress } = await createApp({
|
||||
|
||||
Reference in new issue
Block a user