diff --git a/app/actions/supabase/owners/check-all-auto-reloads.test.ts b/app/actions/supabase/owners/check-all-auto-reloads.test.ts new file mode 100644 index 00000000..df4c28f8 --- /dev/null +++ b/app/actions/supabase/owners/check-all-auto-reloads.test.ts @@ -0,0 +1,454 @@ +process.env.STRIPE_SECRET_KEY = "sk_test_dummy"; + +import { checkAllAutoReloads } from "./check-all-auto-reloads"; +import { supabaseAdmin } from "@/lib/supabase/server"; +import { chargeSavedPaymentMethod } from "@/app/actions/stripe/charge-saved-payment-method"; +import { slackUs } from "@/app/actions/slack/slack-us"; +import { validateAutoReloadSpendingLimit } from "./validate-spending-limit"; + +const mockSupabase = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + neq: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + rpc: jest.fn(), + then: jest.fn().mockImplementation(function (resolve) { + resolve((mockSupabase as any)._resolveValue || { data: null, error: null }); + }), +}; + +jest.mock("@/lib/supabase/server", () => ({ + supabaseAdmin: mockSupabase, +})); + +jest.mock("@/lib/stripe", () => ({ + stripe: { + customers: { retrieve: jest.fn() }, + paymentIntents: { create: jest.fn() }, + }, +})); +jest.mock("@/app/actions/stripe/charge-saved-payment-method"); +jest.mock("@/app/actions/slack/slack-us"); +jest.mock("./validate-spending-limit"); + +describe("checkAllAutoReloads solitary", () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockSupabase as any)._resolveValue = undefined; + // Mock console.log/error to keep test output clean + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ===== solitary ===== + + it("should throw error if fetching owners fails", async () => { + // Verify that if Supabase returns an error during initial fetch, it's thrown + (mockSupabase as any)._resolveValue = { + data: null, + error: { message: "Database connection error" }, + }; + + await expect(checkAllAutoReloads()).rejects.toThrow( + "Failed to fetch owners for auto-reload: Database connection error" + ); + }); + + it("should return success with 0 processed if no owners are eligible", async () => { + // Verify behavior when no owners meet the criteria (enabled, has stripe id, balance <= threshold) + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 100, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 200, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + const result = await checkAllAutoReloads(); + + expect(result).toEqual({ + success: true, + processed: 0, + results: [], + }); + }); + + it("should skip owner if lock cannot be acquired", async () => { + // Verify that if acquire_auto_reload_lock returns 0, the owner is skipped + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 0 }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + reason: "Auto-reload already in progress", + }); + // Ensure lock release is still called in finally + expect(mockSupabase.update).toHaveBeenCalledWith({ auto_reload_in_progress: null }); + }); + + it("should skip owner if target amount is less than or equal to current balance", async () => { + // Verify that if target <= balance, it's skipped to avoid zero/negative charges + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 100, + auto_reload_threshold_usd: 100, // Eligible because balance <= threshold + auto_reload_target_usd: 100, // But target is not > balance + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + reason: "Target amount would be negative or zero", + }); + }); + + it("should skip owner if spending limit is not allowed", async () => { + // Verify that if validateAutoReloadSpendingLimit returns allowed: false, it's skipped + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValueOnce({ + allowed: false, + reason: "Monthly spending limit reached", + }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + reason: "Monthly spending limit reached", + }); + }); + + it("should skip owner if adjusted amount is zero or negative", async () => { + // Verify that if spending limit adjusts the amount to <= 0, it's skipped + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValueOnce({ + allowed: true, + adjustedAmountUsd: 0, + isAdjusted: true, + }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + reason: "Adjusted amount is zero due to spending limit", + }); + }); + + it("should successfully charge owner and log adjusted amount if applicable", async () => { + // Verify happy path where charge is successful and amount was adjusted + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValueOnce({ + allowed: true, + adjustedAmountUsd: 40, // Adjusted from 90 + isAdjusted: true, + }); + (chargeSavedPaymentMethod as jest.Mock).mockResolvedValueOnce({ + success: true, + paymentIntentId: "pi_test_123", + }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: true, + amountCharged: 40, + paymentIntentId: "pi_test_123", + }); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("adjusted from $90")); + }); + + it("should successfully charge owner without adjustment", async () => { + // Verify happy path where charge is successful and no adjustment was needed + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValueOnce({ + allowed: true, + adjustedAmountUsd: 90, + isAdjusted: false, + }); + (chargeSavedPaymentMethod as jest.Mock).mockResolvedValueOnce({ + success: true, + paymentIntentId: "pi_test_123", + }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: true, + amountCharged: 90, + paymentIntentId: "pi_test_123", + }); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining("adjusted from")); + }); + + it("should handle charge failure with error message and notify Slack", async () => { + // Verify that if charge fails, it's recorded, Slack is notified, and console.error is called for non-test customers + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_real_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValueOnce({ + allowed: true, + adjustedAmountUsd: 90, + isAdjusted: false, + }); + (chargeSavedPaymentMethod as jest.Mock).mockResolvedValueOnce({ + success: false, + error: "Card declined", + }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + error: "Card declined", + }); + expect(slackUs).toHaveBeenCalledWith(expect.stringContaining("❌ Auto-reload failed!")); + expect(slackUs).toHaveBeenCalledWith(expect.stringContaining("Error: Card declined")); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("❌ Auto-reload failed for owner 1:"), + "Card declined" + ); + }); + + it("should handle charge failure without error message and not log for test customers", async () => { + // Verify that if charge fails without error message, a default is used, and no console.error for test customers + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_test_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockResolvedValueOnce({ data: 1 }); + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValueOnce({ + allowed: true, + adjustedAmountUsd: 90, + isAdjusted: false, + }); + (chargeSavedPaymentMethod as jest.Mock).mockResolvedValueOnce({ + success: false, + error: null, + }); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + error: "Unknown payment error", + }); + expect(slackUs).toHaveBeenCalledWith(expect.stringContaining("Error: Unknown error")); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("should handle unexpected errors during processing and notify Slack", async () => { + // Verify that if an unexpected error occurs in the loop, it's caught, recorded, and Slack is notified + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockRejectedValueOnce(new Error("Unexpected RPC failure")); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + error: "Unexpected RPC failure", + }); + expect(slackUs).toHaveBeenCalledWith(expect.stringContaining("❌ Auto-reload error!")); + expect(slackUs).toHaveBeenCalledWith(expect.stringContaining("Error: Unexpected RPC failure")); + expect(mockSupabase.update).toHaveBeenCalledWith({ auto_reload_in_progress: null }); + }); + + it("should handle non-Error objects thrown during processing", async () => { + // Verify that if a non-Error object is thrown, it's handled gracefully + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_123", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock).mockRejectedValueOnce("String error"); + + const result = await checkAllAutoReloads(); + + expect(result.results[0]).toEqual({ + ownerId: 1, + success: false, + error: "Unknown error", + }); + }); + + it("should process multiple owners and return correct summary", async () => { + // Verify that multiple owners are processed and the success count is correct + (mockSupabase as any)._resolveValue = { + data: [ + { + owner_id: 1, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_1", + }, + { + owner_id: 2, + credit_balance_usd: 10, + auto_reload_threshold_usd: 50, + auto_reload_target_usd: 100, + stripe_customer_id: "cus_2", + }, + ], + error: null, + }; + + (mockSupabase.rpc as jest.Mock) + .mockResolvedValueOnce({ data: 1 }) // Owner 1 lock + .mockResolvedValueOnce({ data: 1 }); // Owner 2 lock + + (validateAutoReloadSpendingLimit as jest.Mock).mockResolvedValue({ + allowed: true, + adjustedAmountUsd: 90, + isAdjusted: false, + }); + + (chargeSavedPaymentMethod as jest.Mock) + .mockResolvedValueOnce({ success: true, paymentIntentId: "pi_1" }) + .mockResolvedValueOnce({ success: false, error: "Failed" }); + + const result = await checkAllAutoReloads(); + + expect(result.processed).toBe(2); + expect(result.results).toHaveLength(2); + expect(result.results[0].success).toBe(true); + expect(result.results[1].success).toBe(false); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("Auto-reload completed: 1/2 successful") + ); + }); +});