Merge pull request #985 from PR0M3TH3AN/codex/implement-zap-error-handling-and-tests

Handle partial zap receipt failures
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-28 05:38:32 -04:00
commit 7d41964bf5
3 files changed
+288

No files matched your search

+73
View File
@@ -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;
+82
View File
@@ -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;
+133
View File
@@ -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({