{
+ const response = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${resendApiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: "refund@updates.cleanstreamlaundry.com",
+ to,
+ subject: "Refund Request Denied",
+ html: `
+ Refund Request Denied
+ Unfortunately, your refund request for transaction ${transactionId} has been denied.
+ Amount: $${amount}
+ If you have questions about this decision, please contact support.
+ `,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Email send failed: ${errorText}`);
+ }
+}
+
+export async function handleDenyRefund(
+ req: Request,
+ deps: DenyRefundDeps
+): Promise {
+ const url = new URL(req.url);
+ const { userId, transactionId, amount } = extractParams(url);
+
+ await denyRefundInDb(deps.supabase, transactionId);
+
+ const userEmail = await getUserEmail(deps.supabase, userId);
+
+ await deps.sendEmail(userEmail, transactionId, amount);
+
+ const html = `Refund Denied
+ The refund request has been denied and
+ the customer has been notified via email.
+ Transaction: ${transactionId}
+ Amount: $${amount}
+ `;
+
+ return new Response(html, {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ });
+}
\ No newline at end of file
diff --git a/supabase/functions/paymentIntent/index.ts b/supabase/functions/paymentIntent/index.ts
new file mode 100644
index 00000000..8b85ae0c
--- /dev/null
+++ b/supabase/functions/paymentIntent/index.ts
@@ -0,0 +1,40 @@
+import Stripe from "npm:stripe";
+import { handleCreatePaymentIntent } from "./logic.ts";
+
+const CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info",
+};
+
+const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
+ apiVersion: "2024-06-20",
+});
+
+Deno.serve(async (req) => {
+ if (req.method === "OPTIONS") {
+ return new Response(null, { headers: CORS_HEADERS });
+ }
+
+ if (req.method !== "POST") {
+ return new Response("Method Not Allowed", {
+ status: 405,
+ headers: CORS_HEADERS,
+ });
+ }
+
+ try {
+ const response = await handleCreatePaymentIntent(req, { stripe });
+
+ return new Response(response.body, {
+ status: response.status,
+ headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) },
+ });
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ return new Response(JSON.stringify({ error: error.message ?? "Unknown error" }), {
+ status: 400,
+ headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
+ });
+ }
+});
\ No newline at end of file
diff --git a/supabase/functions/paymentIntent/logic.test.ts b/supabase/functions/paymentIntent/logic.test.ts
new file mode 100644
index 00000000..93fc7fb1
--- /dev/null
+++ b/supabase/functions/paymentIntent/logic.test.ts
@@ -0,0 +1,189 @@
+import {
+ assertEquals,
+ assertRejects,
+ } from "https://deno.land/std@0.168.0/testing/asserts.ts";
+ import {
+ validateAmount,
+ createPaymentIntent,
+ handleCreatePaymentIntent,
+ } from "./logic.ts";
+
+ function makeStripeMock(overrides: {
+ clientSecret?: string | null;
+ throwMessage?: string;
+ } = {}) {
+ return {
+ paymentIntents: {
+ create: (_params: unknown) => {
+ if (overrides.throwMessage) {
+ return Promise.reject(new Error(overrides.throwMessage));
+ }
+ const clientSecret = "clientSecret" in overrides
+ ? overrides.clientSecret
+ : "pi_test_secret_abc123";
+ return Promise.resolve({ client_secret: clientSecret });
+ },
+ },
+ } as any;
+ }
+
+ function makeRequest(body: unknown) {
+ return new Request("http://localhost/create-payment-intent", {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ Deno.test("validateAmount — accepts a valid positive integer", () => {
+ assertEquals(validateAmount(2500), 2500);
+ });
+
+ Deno.test("validateAmount — accepts the minimum valid amount (1 cent)", () => {
+ assertEquals(validateAmount(1), 1);
+ });
+
+ Deno.test("validateAmount — throws on undefined", () => {
+ try {
+ validateAmount(undefined);
+ } catch (e) {
+ assertEquals(e instanceof Error, true);
+ assertEquals((e as Error).message, "Missing amount");
+ }
+ });
+
+ Deno.test("validateAmount — throws on null", () => {
+ try {
+ validateAmount(null);
+ } catch (e) {
+ assertEquals(e instanceof Error, true);
+ assertEquals((e as Error).message, "Missing amount");
+ }
+ });
+
+ Deno.test("validateAmount — throws on zero", () => {
+ try {
+ validateAmount(0);
+ } catch (e) {
+ assertEquals(e instanceof Error, true);
+ assertEquals((e as Error).message.startsWith("Invalid amount"), true);
+ }
+ });
+
+ Deno.test("validateAmount — throws on negative number", () => {
+ try {
+ validateAmount(-500);
+ } catch (e) {
+ assertEquals(e instanceof Error, true);
+ assertEquals((e as Error).message.startsWith("Invalid amount"), true);
+ }
+ });
+
+ Deno.test("validateAmount — throws on float (Stripe requires whole cents)", () => {
+ try {
+ validateAmount(24.99);
+ } catch (e) {
+ assertEquals(e instanceof Error, true);
+ assertEquals((e as Error).message.startsWith("Invalid amount"), true);
+ }
+ });
+
+ Deno.test("validateAmount — throws on string", () => {
+ try {
+ validateAmount("2500");
+ } catch (e) {
+ assertEquals(e instanceof Error, true);
+ assertEquals((e as Error).message.startsWith("Invalid amount"), true);
+ }
+ });
+
+ Deno.test("createPaymentIntent — returns clientSecret on success", async () => {
+ const stripe = makeStripeMock();
+ const result = await createPaymentIntent(stripe, 2500);
+
+ assertEquals(result.clientSecret, "pi_test_secret_abc123");
+ });
+
+ Deno.test("createPaymentIntent — passes correct amount to Stripe", async () => {
+ let capturedParams: any;
+ const stripe = {
+ paymentIntents: {
+ create: (params: unknown) => {
+ capturedParams = params;
+ return Promise.resolve({ client_secret: "pi_test_secret" });
+ },
+ },
+ } as any;
+
+ await createPaymentIntent(stripe, 4999);
+
+ assertEquals(capturedParams.amount, 4999);
+ assertEquals(capturedParams.currency, "usd");
+ assertEquals(capturedParams.payment_method_types, ["card"]);
+ });
+
+ Deno.test("createPaymentIntent — handles null client_secret from Stripe", async () => {
+ const stripe = makeStripeMock({ clientSecret: null });
+ const result = await createPaymentIntent(stripe, 2500);
+
+ assertEquals(result.clientSecret, null);
+ });
+
+ Deno.test("createPaymentIntent — throws when Stripe rejects", async () => {
+ const stripe = makeStripeMock({ throwMessage: "Your card was declined." });
+
+ await assertRejects(
+ () => createPaymentIntent(stripe, 2500),
+ Error,
+ "Your card was declined."
+ );
+ });
+
+
+ Deno.test("handleCreatePaymentIntent — returns 200 with clientSecret", async () => {
+ const req = makeRequest({ amount: 2500 });
+ const res = await handleCreatePaymentIntent(req, { stripe: makeStripeMock() });
+ const body = await res.json();
+
+ assertEquals(res.status, 200);
+ assertEquals(body.clientSecret, "pi_test_secret_abc123");
+ });
+
+ Deno.test("handleCreatePaymentIntent — throws Missing amount when body has no amount", async () => {
+ const req = makeRequest({});
+
+ await assertRejects(
+ () => handleCreatePaymentIntent(req, { stripe: makeStripeMock() }),
+ Error,
+ "Missing amount"
+ );
+ });
+
+ Deno.test("handleCreatePaymentIntent — throws Invalid amount for a float", async () => {
+ const req = makeRequest({ amount: 19.99 });
+
+ await assertRejects(
+ () => handleCreatePaymentIntent(req, { stripe: makeStripeMock() }),
+ Error,
+ "Invalid amount"
+ );
+ });
+
+ Deno.test("handleCreatePaymentIntent — throws when Stripe fails", async () => {
+ const req = makeRequest({ amount: 2500 });
+
+ await assertRejects(
+ () => handleCreatePaymentIntent(req, {
+ stripe: makeStripeMock({ throwMessage: "Stripe API unavailable" }),
+ }),
+ Error,
+ "Stripe API unavailable"
+ );
+ });
+
+ Deno.test("handleCreatePaymentIntent — response Content-Type is application/json", async () => {
+ const req = makeRequest({ amount: 1000 });
+ const res = await handleCreatePaymentIntent(req, { stripe: makeStripeMock() });
+
+ assertEquals(res.headers.get("Content-Type"), "application/json");
+ });
\ No newline at end of file
diff --git a/supabase/functions/paymentIntent/logic.ts b/supabase/functions/paymentIntent/logic.ts
new file mode 100644
index 00000000..a308762b
--- /dev/null
+++ b/supabase/functions/paymentIntent/logic.ts
@@ -0,0 +1,46 @@
+import Stripe from "npm:stripe";
+
+export interface PaymentIntentDeps {
+ stripe: Stripe;
+}
+
+export interface PaymentIntentResult {
+ clientSecret: string | null;
+}
+
+export function validateAmount(amount: unknown): number {
+ if (amount === undefined || amount === null) {
+ throw new Error("Missing amount");
+ }
+ if (typeof amount !== "number" || !Number.isInteger(amount) || amount <= 0) {
+ throw new Error("Invalid amount: must be a positive integer (cents)");
+ }
+ return amount;
+}
+
+export async function createPaymentIntent(
+ stripe: Stripe,
+ amount: number
+): Promise {
+ const intent = await stripe.paymentIntents.create({
+ amount,
+ currency: "usd",
+ payment_method_types: ["card"],
+ });
+
+ return { clientSecret: intent.client_secret };
+}
+
+export async function handleCreatePaymentIntent(
+ req: Request,
+ deps: PaymentIntentDeps
+): Promise {
+ const body = await req.json();
+ const amount = validateAmount(body?.amount);
+ const result = await createPaymentIntent(deps.stripe, amount);
+
+ return new Response(JSON.stringify(result), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
\ No newline at end of file
diff --git a/supabase/functions/ping-device/index.ts b/supabase/functions/ping-device/index.ts
new file mode 100644
index 00000000..d62c2285
--- /dev/null
+++ b/supabase/functions/ping-device/index.ts
@@ -0,0 +1,77 @@
+import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+import { handleMachineRequest } from "./logic.ts";
+
+const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
+const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
+const supabase = createClient(supabaseUrl, anonKey);
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ "Access-Control-Max-Age": "86400",
+};
+
+async function getMachineStatusFromSupabase(deviceId: string) {
+ try {
+ const { data, error } = await supabase
+ .from("Machines")
+ .select("Status")
+ .eq("id", deviceId)
+ .single();
+
+ if (error || !data) return "error";
+
+ const status = data.Status?.toLowerCase();
+
+ const valid = ["idle", "in-use", "offline", "error"];
+
+ return valid.includes(status) ? status : "offline";
+ } catch {
+ return "error";
+ }
+}
+
+serve(async (req) => {
+ if (req.method === "OPTIONS") {
+ return new Response(null, {
+ status: 200,
+ headers: corsHeaders,
+ });
+ }
+
+ try {
+ const text = await req.text();
+ const body = text ? JSON.parse(text) : {};
+
+ const result = await handleMachineRequest(body, {
+ getMachineStatus: getMachineStatusFromSupabase,
+ random: Math.random,
+ delay: (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms)),
+ });
+
+ return new Response(JSON.stringify(result.body), {
+ status: result.status,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ });
+ } catch (error: any) {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: error.message || "Internal server error",
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ }
+ );
+ }
+});
\ No newline at end of file
diff --git a/supabase/functions/ping-device/logic.test.ts b/supabase/functions/ping-device/logic.test.ts
new file mode 100644
index 00000000..2c5b2fef
--- /dev/null
+++ b/supabase/functions/ping-device/logic.test.ts
@@ -0,0 +1,61 @@
+export type MachineStatus =
+ | "idle"
+ | "in-use"
+ | "offline"
+ | "error";
+
+export interface Dependencies {
+ getMachineStatus: (deviceId: string) => Promise;
+ random: () => number;
+ delay: (ms: number) => Promise;
+}
+
+export async function handleMachineRequest(
+ body: any,
+ deps: Dependencies
+) {
+ const { getMachineStatus, random, delay } = deps;
+
+ const deviceId = body?.deviceId;
+
+ if (!deviceId) {
+ return {
+ status: 400,
+ body: {
+ error: "deviceId is required",
+ receivedBody: body,
+ },
+ };
+ }
+
+ const success = random() < 0.95;
+ const responseDelay = Math.floor(random() * 150) + 50;
+
+ await delay(responseDelay);
+
+ const machineStatus = await getMachineStatus(deviceId);
+
+ if (success) {
+ return {
+ status: 200,
+ body: {
+ success: true,
+ deviceId,
+ message: machineStatus,
+ timestamp: new Date().toISOString(),
+ responseTime: `${responseDelay}ms`,
+ },
+ };
+ }
+
+ return {
+ status: 503,
+ body: {
+ success: false,
+ deviceId,
+ error: "Device unreachable or timeout",
+ timestamp: new Date().toISOString(),
+ responseTime: `${responseDelay}ms`,
+ },
+ };
+}
\ No newline at end of file
diff --git a/supabase/functions/ping-device/logic.ts b/supabase/functions/ping-device/logic.ts
new file mode 100644
index 00000000..2c5b2fef
--- /dev/null
+++ b/supabase/functions/ping-device/logic.ts
@@ -0,0 +1,61 @@
+export type MachineStatus =
+ | "idle"
+ | "in-use"
+ | "offline"
+ | "error";
+
+export interface Dependencies {
+ getMachineStatus: (deviceId: string) => Promise;
+ random: () => number;
+ delay: (ms: number) => Promise;
+}
+
+export async function handleMachineRequest(
+ body: any,
+ deps: Dependencies
+) {
+ const { getMachineStatus, random, delay } = deps;
+
+ const deviceId = body?.deviceId;
+
+ if (!deviceId) {
+ return {
+ status: 400,
+ body: {
+ error: "deviceId is required",
+ receivedBody: body,
+ },
+ };
+ }
+
+ const success = random() < 0.95;
+ const responseDelay = Math.floor(random() * 150) + 50;
+
+ await delay(responseDelay);
+
+ const machineStatus = await getMachineStatus(deviceId);
+
+ if (success) {
+ return {
+ status: 200,
+ body: {
+ success: true,
+ deviceId,
+ message: machineStatus,
+ timestamp: new Date().toISOString(),
+ responseTime: `${responseDelay}ms`,
+ },
+ };
+ }
+
+ return {
+ status: 503,
+ body: {
+ success: false,
+ deviceId,
+ error: "Device unreachable or timeout",
+ timestamp: new Date().toISOString(),
+ responseTime: `${responseDelay}ms`,
+ },
+ };
+}
\ No newline at end of file
diff --git a/supabase/functions/refund-email/index.ts b/supabase/functions/refund-email/index.ts
new file mode 100644
index 00000000..1fe7392f
--- /dev/null
+++ b/supabase/functions/refund-email/index.ts
@@ -0,0 +1,85 @@
+import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
+import { handleRefundRequest } from "./logic.ts";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ "Access-Control-Max-Age": "86400",
+};
+
+serve(async (req) => {
+ if (req.method === "OPTIONS") {
+ return new Response(null, {
+ status: 200,
+ headers: corsHeaders,
+ });
+ }
+
+ try {
+ let body;
+
+ try {
+ const text = await req.text();
+ body = text ? JSON.parse(text) : {};
+ } catch {
+ return new Response(
+ JSON.stringify({ error: "Invalid JSON body" }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ }
+ );
+ }
+
+ const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!;
+
+ const result = await handleRefundRequest(body, {
+ sendEmail: async ({ subject, html }) => {
+ const response = await fetch(
+ "https://api.resend.com/emails",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${RESEND_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: "refund@updates.cleanstreamlaundry.com",
+ to: "yoder453@gmail.com",
+ subject,
+ html,
+ }),
+ }
+ );
+
+ return response.json();
+ },
+ });
+
+ return new Response(JSON.stringify(result.body), {
+ status: result.status,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ });
+ } catch (error: any) {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: error.message || "Internal server error",
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ }
+ );
+ }
+});
\ No newline at end of file
diff --git a/supabase/functions/refund-email/logic.test.ts b/supabase/functions/refund-email/logic.test.ts
new file mode 100644
index 00000000..5ecf6fc0
--- /dev/null
+++ b/supabase/functions/refund-email/logic.test.ts
@@ -0,0 +1,89 @@
+import {
+ handleRefundRequest,
+ } from "./logic.ts";
+
+ import {
+ assertEquals,
+ assert,
+ } from "https://deno.land/std@0.224.0/testing/asserts.ts";
+
+ function createDeps() {
+ let capturedSubject = "";
+ let capturedHtml = "";
+
+ return {
+ deps: {
+ sendEmail: async ({ subject, html }: any) => {
+ capturedSubject = subject;
+ capturedHtml = html;
+ return { id: "email_123" };
+ },
+ },
+ getCaptured: () => ({
+ subject: capturedSubject,
+ html: capturedHtml,
+ }),
+ };
+ }
+
+ const validBody = {
+ username: "John",
+ user_id: "u123",
+ transaction_id: "t456",
+ amount: 25,
+ description: "Machine ate my sock",
+ userAttempts: 2,
+ };
+
+ Deno.test("returns 400 if required fields missing", async () => {
+ const { deps } = createDeps();
+
+ const result = await handleRefundRequest({}, deps);
+
+ assertEquals(result.status, 400);
+ assertEquals(result.body.error, "Missing required fields");
+ });
+
+ Deno.test("returns 200 on valid request", async () => {
+ const { deps } = createDeps();
+
+ const result = await handleRefundRequest(validBody, deps);
+
+ assertEquals(result.status, 200);
+ assertEquals(result.body.success, true);
+ assertEquals(result.body.resend.id, "email_123");
+ });
+
+ Deno.test("email subject contains username", async () => {
+ const { deps, getCaptured } = createDeps();
+
+ await handleRefundRequest(validBody, deps);
+
+ const { subject } = getCaptured();
+
+ assert(subject.includes("John"));
+ });
+
+ Deno.test("email contains approve and deny links", async () => {
+ const { deps, getCaptured } = createDeps();
+
+ await handleRefundRequest(validBody, deps);
+
+ const { html } = getCaptured();
+
+ assert(html.includes("approveRefund"));
+ assert(html.includes("denyRefund"));
+ assert(html.includes("u123"));
+ assert(html.includes("t456"));
+ });
+
+ Deno.test("email includes refund details", async () => {
+ const { deps, getCaptured } = createDeps();
+
+ await handleRefundRequest(validBody, deps);
+
+ const { html } = getCaptured();
+
+ assert(html.includes("Machine ate my sock"));
+ assert(html.includes("$25"));
+ });
\ No newline at end of file
diff --git a/supabase/functions/refund-email/logic.ts b/supabase/functions/refund-email/logic.ts
new file mode 100644
index 00000000..9e9c8b7c
--- /dev/null
+++ b/supabase/functions/refund-email/logic.ts
@@ -0,0 +1,76 @@
+export interface RefundRequestBody {
+ username?: string;
+ user_id?: string;
+ transaction_id?: string;
+ amount?: number;
+ description?: string;
+ userAttempts?: number;
+ }
+
+ export interface Dependencies {
+ sendEmail: (params: {
+ subject: string;
+ html: string;
+ }) => Promise;
+ }
+
+ export async function handleRefundRequest(
+ body: RefundRequestBody,
+ deps: Dependencies
+ ) {
+ const { sendEmail } = deps;
+
+ const {
+ username,
+ user_id,
+ transaction_id,
+ amount,
+ description,
+ userAttempts,
+ } = body;
+
+ if (!username || !user_id || !transaction_id || !amount) {
+ return {
+ status: 400,
+ body: {
+ error: "Missing required fields",
+ received: body,
+ },
+ };
+ }
+
+ const approveLink =
+ `https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/approveRefund` +
+ `?user_id=${user_id}&transaction_id=${transaction_id}&amount=${amount}`;
+
+ const denyLink =
+ `https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/denyRefund` +
+ `?user_id=${user_id}&transaction_id=${transaction_id}&amount=${amount}`;
+
+ const emailBody = `
+ Refund Request Received
+ Name: ${username}
+ User ID: ${user_id}
+ Transaction ID: ${transaction_id}
+ Amount: $${amount}
+ Reason: ${description}
+ Number of refund attempts: ${userAttempts}
+
+ `;
+
+ const emailResult = await sendEmail({
+ subject: `New Refund Request - ${username}`,
+ html: emailBody,
+ });
+
+ return {
+ status: 200,
+ body: {
+ success: true,
+ resend: emailResult,
+ },
+ };
+ }
\ No newline at end of file
diff --git a/supabase/functions/resetToken/index.ts b/supabase/functions/resetToken/index.ts
new file mode 100644
index 00000000..9ac75465
--- /dev/null
+++ b/supabase/functions/resetToken/index.ts
@@ -0,0 +1,29 @@
+import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+import { handleExchangeCode } from "./logic.ts";
+
+serve(async (req) => {
+ try {
+ const body = await req.json();
+
+ const supabase = createClient(
+ Deno.env.get("SUPABASE_URL")!,
+ Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
+ );
+
+ const result = await handleExchangeCode(body, {
+ exchangeCodeForSession: async (code: string) => {
+ const { data, error } =
+ await supabase.auth.exchangeCodeForSession(code);
+
+ if (error) return {};
+ return { user: data?.user };
+ },
+ });
+
+ return new Response(result.body, { status: result.status });
+
+ } catch {
+ return new Response("Bad Request", { status: 400 });
+ }
+});
\ No newline at end of file
diff --git a/supabase/functions/resetToken/logic.test.ts b/supabase/functions/resetToken/logic.test.ts
new file mode 100644
index 00000000..5ec3b9c0
--- /dev/null
+++ b/supabase/functions/resetToken/logic.test.ts
@@ -0,0 +1,61 @@
+import {
+ handleExchangeCode,
+ } from "./logic.ts";
+
+ import {
+ assertEquals,
+ } from "https://deno.land/std@0.224.0/testing/asserts.ts";
+
+
+ function createDeps(options?: {
+ user?: any;
+ }) {
+ return {
+ exchangeCodeForSession: async (_code: string) => {
+ return options?.user ? { user: options.user } : {};
+ },
+ };
+ }
+
+
+ Deno.test("returns 400 if code missing", async () => {
+ const result = await handleExchangeCode({}, createDeps());
+
+ assertEquals(result.status, 400);
+ assertEquals(result.body, "Missing code");
+ });
+
+ Deno.test("returns 401 if no user returned", async () => {
+ const result = await handleExchangeCode(
+ { code: "abc" },
+ createDeps()
+ );
+
+ assertEquals(result.status, 401);
+ assertEquals(result.body, "Invalid or expired code");
+ });
+
+ Deno.test("returns 200 if user exists", async () => {
+ const result = await handleExchangeCode(
+ { code: "abc" },
+ createDeps({ user: { id: "123" } })
+ );
+
+ assertEquals(result.status, 200);
+ assertEquals(result.body, "OK");
+ });
+
+ Deno.test("calls dependency with correct code", async () => {
+ let capturedCode = "";
+
+ const deps = {
+ exchangeCodeForSession: async (code: string) => {
+ capturedCode = code;
+ return { user: { id: "123" } };
+ },
+ };
+
+ await handleExchangeCode({ code: "specialCode" }, deps);
+
+ assertEquals(capturedCode, "specialCode");
+ });
\ No newline at end of file
diff --git a/supabase/functions/resetToken/logic.ts b/supabase/functions/resetToken/logic.ts
new file mode 100644
index 00000000..6178d5ee
--- /dev/null
+++ b/supabase/functions/resetToken/logic.ts
@@ -0,0 +1,26 @@
+export interface Dependencies {
+ exchangeCodeForSession: (
+ code: string
+ ) => Promise<{ user?: any }>;
+ }
+
+ export async function handleExchangeCode(
+ body: any,
+ deps: Dependencies
+ ) {
+ const { exchangeCodeForSession } = deps;
+
+ const code = body?.code;
+
+ if (!code) {
+ return { status: 400, body: "Missing code" };
+ }
+
+ const result = await exchangeCodeForSession(code);
+
+ if (!result?.user) {
+ return { status: 401, body: "Invalid or expired code" };
+ }
+
+ return { status: 200, body: "OK" };
+ }
\ No newline at end of file
diff --git a/supabase/functions/stripeWebhook/index.ts b/supabase/functions/stripeWebhook/index.ts
new file mode 100644
index 00000000..af9598e6
--- /dev/null
+++ b/supabase/functions/stripeWebhook/index.ts
@@ -0,0 +1,46 @@
+import { serve } from "https://deno.land/std@0.223.0/http/server.ts";
+import Stripe from "npm:stripe@14";
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+import { handleStripeWebhook } from "./logic.ts";
+
+const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
+ apiVersion: "2023-10-16",
+ httpClient: Stripe.createFetchHttpClient(),
+});
+
+serve(async (req) => {
+ const signature = req.headers.get("stripe-signature");
+ const rawBody = await req.text();
+
+ const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!;
+
+ const result = await handleStripeWebhook(
+ { rawBody, signature },
+ {
+ verifyAndConstructEvent: async (body, sig) => {
+ return await stripe.webhooks.constructEventAsync(
+ body,
+ sig,
+ webhookSecret,
+ undefined,
+ Stripe.createSubtleCryptoProvider()
+ );
+ },
+
+ broadcastPaymentSuccess: async (payload) => {
+ const supabase = createClient(
+ Deno.env.get("SUPABASE_URL")!,
+ Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
+ );
+
+ await supabase.channel("payments").send({
+ type: "broadcast",
+ event: "payment_success",
+ payload,
+ });
+ },
+ }
+ );
+
+ return new Response(result.body, { status: result.status });
+});
\ No newline at end of file
diff --git a/supabase/functions/stripeWebhook/logic.test.ts b/supabase/functions/stripeWebhook/logic.test.ts
new file mode 100644
index 00000000..29c673a5
--- /dev/null
+++ b/supabase/functions/stripeWebhook/logic.test.ts
@@ -0,0 +1,105 @@
+import {
+ handleStripeWebhook,
+ } from "./logic.ts";
+
+ import {
+ assertEquals,
+ assert,
+ } from "https://deno.land/std@0.224.0/testing/asserts.ts";
+
+
+ function createDeps(options?: {
+ eventType?: string;
+ shouldThrow?: boolean;
+ }) {
+ let broadcastCalled = false;
+ let broadcastPayload: any = null;
+
+ return {
+ deps: {
+ verifyAndConstructEvent: async () => {
+ if (options?.shouldThrow) {
+ throw new Error("Invalid signature");
+ }
+
+ return {
+ type: options?.eventType ?? "checkout.session.completed",
+ data: {
+ object: {
+ metadata: { user_id: "user123" },
+ amount_total: 5000,
+ },
+ },
+ };
+ },
+
+ broadcastPaymentSuccess: async (payload: any) => {
+ broadcastCalled = true;
+ broadcastPayload = payload;
+ },
+ },
+
+ getBroadcastInfo: () => ({
+ broadcastCalled,
+ broadcastPayload,
+ }),
+ };
+ }
+
+
+ Deno.test("returns 400 if signature missing", async () => {
+ const { deps } = createDeps();
+
+ const result = await handleStripeWebhook(
+ { rawBody: "{}", signature: null },
+ deps
+ );
+
+ assertEquals(result.status, 400);
+ assertEquals(result.body, "No signature");
+ });
+
+ Deno.test("returns 400 if verification fails", async () => {
+ const { deps } = createDeps({ shouldThrow: true });
+
+ const result = await handleStripeWebhook(
+ { rawBody: "{}", signature: "sig" },
+ deps
+ );
+
+ assertEquals(result.status, 400);
+ assert(result.body.includes("Invalid signature"));
+ });
+
+ Deno.test("broadcasts on checkout.session.completed", async () => {
+ const { deps, getBroadcastInfo } = createDeps();
+
+ const result = await handleStripeWebhook(
+ { rawBody: "{}", signature: "sig" },
+ deps
+ );
+
+ const { broadcastCalled, broadcastPayload } =
+ getBroadcastInfo();
+
+ assertEquals(result.status, 200);
+ assertEquals(broadcastCalled, true);
+ assertEquals(broadcastPayload.user_id, "user123");
+ assertEquals(broadcastPayload.amount, 5000);
+ });
+
+ Deno.test("does not broadcast for other event types", async () => {
+ const { deps, getBroadcastInfo } = createDeps({
+ eventType: "payment.failed",
+ });
+
+ const result = await handleStripeWebhook(
+ { rawBody: "{}", signature: "sig" },
+ deps
+ );
+
+ const { broadcastCalled } = getBroadcastInfo();
+
+ assertEquals(result.status, 200);
+ assertEquals(broadcastCalled, false);
+ });
\ No newline at end of file
diff --git a/supabase/functions/stripeWebhook/logic.ts b/supabase/functions/stripeWebhook/logic.ts
new file mode 100644
index 00000000..ca5d2d07
--- /dev/null
+++ b/supabase/functions/stripeWebhook/logic.ts
@@ -0,0 +1,51 @@
+export interface StripeEvent {
+ type: string;
+ data: {
+ object: any;
+ };
+ }
+
+ export interface Dependencies {
+ verifyAndConstructEvent: (rawBody: string, signature: string) => Promise;
+ broadcastPaymentSuccess: (payload: {
+ user_id?: string;
+ amount?: number;
+ }) => Promise;
+ }
+
+ export async function handleStripeWebhook(
+ params: {
+ rawBody: string;
+ signature: string | null;
+ },
+ deps: Dependencies
+ ) {
+ const { rawBody, signature } = params;
+ const { verifyAndConstructEvent, broadcastPaymentSuccess } = deps;
+
+ if (!signature) {
+ return { status: 400, body: "No signature" };
+ }
+
+ let event: StripeEvent;
+
+ try {
+ event = await verifyAndConstructEvent(rawBody, signature);
+ } catch (err: any) {
+ return {
+ status: 400,
+ body: `Webhook Error: ${err.message}`,
+ };
+ }
+
+ if (event.type === "checkout.session.completed") {
+ const session = event.data.object;
+
+ await broadcastPaymentSuccess({
+ user_id: session.metadata?.user_id,
+ amount: session.amount_total,
+ });
+ }
+
+ return { status: 200, body: "OK" };
+ }
\ No newline at end of file
diff --git a/supabase/functions/verifyPayment/index.ts b/supabase/functions/verifyPayment/index.ts
new file mode 100644
index 00000000..273ccf59
--- /dev/null
+++ b/supabase/functions/verifyPayment/index.ts
@@ -0,0 +1,29 @@
+import Stripe from "npm:stripe@^14.0.0";
+import { serve } from "https://deno.land/std/http/server.ts";
+import { handleCheckPaymentResult } from "./logic.ts";
+
+const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
+ apiVersion: "2023-10-16",
+});
+
+serve(async (req) => {
+ try {
+ const body = await req.json();
+
+ const result = await handleCheckPaymentResult(body, {
+ retrieveSession: async (sessionId: string) => {
+ return await stripe.checkout.sessions.retrieve(sessionId);
+ },
+ });
+
+ return new Response(JSON.stringify(result.body), {
+ status: result.status,
+ headers: { "Content-Type": "application/json" },
+ });
+ } catch {
+ return new Response(
+ JSON.stringify({ error: "Bad Request" }),
+ { status: 400 }
+ );
+ }
+});
\ No newline at end of file
diff --git a/supabase/functions/verifyPayment/logic.test.ts b/supabase/functions/verifyPayment/logic.test.ts
new file mode 100644
index 00000000..4e8e7c01
--- /dev/null
+++ b/supabase/functions/verifyPayment/logic.test.ts
@@ -0,0 +1,89 @@
+import {
+ handleCheckPaymentResult,
+ } from "./logic.ts";
+
+ import {
+ assertEquals,
+ } from "https://deno.land/std@0.224.0/testing/asserts.ts";
+
+
+ function createDeps(options?: {
+ paymentStatus?: string;
+ shouldThrow?: boolean;
+ }) {
+ let capturedSessionId = "";
+
+ return {
+ deps: {
+ retrieveSession: async (sessionId: string) => {
+ capturedSessionId = sessionId;
+
+ if (options?.shouldThrow) {
+ throw new Error("Stripe error");
+ }
+
+ return {
+ payment_status: options?.paymentStatus ?? "unpaid",
+ };
+ },
+ },
+ getCapturedSessionId: () => capturedSessionId,
+ };
+ }
+
+
+ Deno.test("returns 400 if session_id missing", async () => {
+ const { deps } = createDeps();
+
+ const result = await handleCheckPaymentResult({}, deps);
+
+ assertEquals(result.status, 400);
+ assertEquals(result.body.error, "Missing session_id");
+ });
+
+ Deno.test("returns paid: true when payment_status is paid", async () => {
+ const { deps } = createDeps({ paymentStatus: "paid" });
+
+ const result = await handleCheckPaymentResult(
+ { session_id: "sess_123" },
+ deps
+ );
+
+ assertEquals(result.status, 200);
+ assertEquals(result.body.paid, true);
+ });
+
+ Deno.test("returns paid: false when payment_status is not paid", async () => {
+ const { deps } = createDeps({ paymentStatus: "unpaid" });
+
+ const result = await handleCheckPaymentResult(
+ { session_id: "sess_123" },
+ deps
+ );
+
+ assertEquals(result.status, 200);
+ assertEquals(result.body.paid, false);
+ });
+
+ Deno.test("calls retrieveSession with correct session_id", async () => {
+ const { deps, getCapturedSessionId } = createDeps();
+
+ await handleCheckPaymentResult(
+ { session_id: "sess_abc" },
+ deps
+ );
+
+ assertEquals(getCapturedSessionId(), "sess_abc");
+ });
+
+ Deno.test("returns 400 if Stripe throws", async () => {
+ const { deps } = createDeps({ shouldThrow: true });
+
+ const result = await handleCheckPaymentResult(
+ { session_id: "sess_123" },
+ deps
+ );
+
+ assertEquals(result.status, 400);
+ assertEquals(result.body.error, "Stripe error");
+ });
\ No newline at end of file
diff --git a/supabase/functions/verifyPayment/logic.ts b/supabase/functions/verifyPayment/logic.ts
new file mode 100644
index 00000000..216503f8
--- /dev/null
+++ b/supabase/functions/verifyPayment/logic.ts
@@ -0,0 +1,41 @@
+export interface StripeSession {
+ payment_status?: string;
+ }
+
+ export interface Dependencies {
+ retrieveSession: (sessionId: string) => Promise;
+ }
+
+ export async function handleCheckPaymentResult(
+ body: any,
+ deps: Dependencies
+ ) {
+ const { retrieveSession } = deps;
+
+ const sessionId = body?.session_id;
+
+ if (!sessionId) {
+ return {
+ status: 400,
+ body: { error: "Missing session_id" },
+ };
+ }
+
+ try {
+ const session = await retrieveSession(sessionId);
+
+ return {
+ status: 200,
+ body: {
+ paid: session.payment_status === "paid",
+ },
+ };
+ } catch (err: any) {
+ return {
+ status: 400,
+ body: {
+ error: err.message || "Failed to retrieve session",
+ },
+ };
+ }
+ }
\ No newline at end of file
diff --git a/supabase/functions/wakeDevice/index.ts b/supabase/functions/wakeDevice/index.ts
new file mode 100644
index 00000000..52fe6d7b
--- /dev/null
+++ b/supabase/functions/wakeDevice/index.ts
@@ -0,0 +1,66 @@
+import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
+import { handleWakeDevice } from "./logic.ts";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ "Access-Control-Max-Age": "86400",
+};
+
+serve(async (req) => {
+ if (req.method === "OPTIONS") {
+ return new Response(null, {
+ status: 200,
+ headers: corsHeaders,
+ });
+ }
+
+ try {
+ let body;
+ try {
+ const text = await req.text();
+ body = text ? JSON.parse(text) : {};
+ } catch {
+ return new Response(
+ JSON.stringify({ error: "Invalid JSON body" }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ }
+ );
+ }
+
+ const result = await handleWakeDevice(body, {
+ random: Math.random,
+ delay: (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms)),
+ now: () => new Date(),
+ });
+
+ return new Response(JSON.stringify(result.body), {
+ status: result.status,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ });
+ } catch (error: any) {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: error.message || "Internal server error",
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ ...corsHeaders,
+ },
+ }
+ );
+ }
+});
\ No newline at end of file
diff --git a/supabase/functions/wakeDevice/logic.test.ts b/supabase/functions/wakeDevice/logic.test.ts
new file mode 100644
index 00000000..e0a42f01
--- /dev/null
+++ b/supabase/functions/wakeDevice/logic.test.ts
@@ -0,0 +1,89 @@
+import {
+ handleWakeDevice,
+ } from "./logic.ts";
+
+ import {
+ assertEquals,
+ } from "https://deno.land/std@0.224.0/testing/asserts.ts";
+
+
+ function createDeps(options?: {
+ randomValues?: number[];
+ }) {
+ let delayCalledWith = 0;
+
+ const randomValues = options?.randomValues ?? [0.1, 0.5];
+ let randomIndex = 0;
+
+ return {
+ deps: {
+ random: () => randomValues[randomIndex++],
+ delay: async (ms: number) => {
+ delayCalledWith = ms;
+ },
+ now: () => new Date("2024-01-01T00:00:00.000Z"),
+ },
+ getDelay: () => delayCalledWith,
+ };
+ }
+
+
+ Deno.test("returns 400 if deviceId missing", async () => {
+ const { deps } = createDeps();
+
+ const result = await handleWakeDevice({}, deps);
+
+ assertEquals(result.status, 400);
+ assertEquals(result.body.error, "deviceId is required");
+ });
+
+ Deno.test("returns success when random < 0.95", async () => {
+ // First random for success = 0.1 (success)
+ // Second random for delay = 0.5
+ const { deps, getDelay } = createDeps({
+ randomValues: [0.1, 0.5],
+ });
+
+ const result = await handleWakeDevice(
+ { deviceId: "abc123" },
+ deps
+ );
+
+ assertEquals(result.status, 200);
+ assertEquals(result.body.success, true);
+ assertEquals(result.body.deviceId, "abc123");
+ assertEquals(result.body.timestamp, "2024-01-01T00:00:00.000Z");
+
+ // delay = floor(0.5 * 150) + 50 = 125
+ assertEquals(getDelay(), 125);
+ assertEquals(result.body.responseTime, "125ms");
+ });
+
+ Deno.test("returns 503 when random >= 0.95", async () => {
+ const { deps } = createDeps({
+ randomValues: [0.99, 0.3],
+ });
+
+ const result = await handleWakeDevice(
+ { deviceId: "abc123" },
+ deps
+ );
+
+ assertEquals(result.status, 503);
+ assertEquals(result.body.success, false);
+ assertEquals(result.body.error, "Device unreachable or timeout");
+ });
+
+ Deno.test("delay is awaited with correct ms", async () => {
+ const { deps, getDelay } = createDeps({
+ randomValues: [0.1, 0.0], // minimum delay
+ });
+
+ await handleWakeDevice(
+ { deviceId: "abc123" },
+ deps
+ );
+
+ // floor(0 * 150) + 50 = 50
+ assertEquals(getDelay(), 50);
+ });
\ No newline at end of file
diff --git a/supabase/functions/wakeDevice/logic.ts b/supabase/functions/wakeDevice/logic.ts
new file mode 100644
index 00000000..a82f1f56
--- /dev/null
+++ b/supabase/functions/wakeDevice/logic.ts
@@ -0,0 +1,55 @@
+export interface Dependencies {
+ random: () => number;
+ delay: (ms: number) => Promise;
+ now: () => Date;
+ }
+
+ export async function handleWakeDevice(
+ body: any,
+ deps: Dependencies
+ ) {
+ const { random, delay, now } = deps;
+
+ const deviceId = body?.deviceId;
+
+ if (!deviceId) {
+ return {
+ status: 400,
+ body: {
+ error: "deviceId is required",
+ receivedBody: body,
+ },
+ };
+ }
+
+ const success = random() < 0.95;
+ const responseDelay = Math.floor(random() * 150) + 50;
+
+ await delay(responseDelay);
+
+ const timestamp = now().toISOString();
+
+ if (success) {
+ return {
+ status: 200,
+ body: {
+ success: true,
+ deviceId,
+ message: "Device wake signal sent successfully",
+ timestamp,
+ responseTime: `${responseDelay}ms`,
+ },
+ };
+ }
+
+ return {
+ status: 503,
+ body: {
+ success: false,
+ deviceId,
+ error: "Device unreachable or timeout",
+ timestamp,
+ responseTime: `${responseDelay}ms`,
+ },
+ };
+ }
\ No newline at end of file
diff --git a/test/logic/parsing/location_parser_test.dart b/test/logic/parsing/location_parser_test.dart
index 38d5dafc..0d035dee 100644
--- a/test/logic/parsing/location_parser_test.dart
+++ b/test/logic/parsing/location_parser_test.dart
@@ -3,12 +3,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:clean_stream_laundry_app/widgets/map_marker.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:geolocator/geolocator.dart';
+
+class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {}
void main() {
group('LocationParser', () {
test('parseLocations returns empty list when input is empty', () {
final result = LocationParser.parseLocations([]);
-
expect(result, isEmpty);
});
@@ -98,57 +101,413 @@ void main() {
expect(result[0].point.latitude, 40.7128);
expect(result[1].point.latitude, 51.5074);
});
- group('LocationParser', () {
- // Your existing tests...
- // Add this widget test
- testWidgets('parseLocations creates fully initialized Marker objects', (WidgetTester tester) async {
- final locations = [
- {'Latitude': 40.7128, 'Longitude': -74.0060},
- ];
+ testWidgets('parseLocations creates fully initialized Marker objects',
+ (WidgetTester tester) async {
+ final locations = [
+ {'Latitude': 40.7128, 'Longitude': -74.0060},
+ ];
- final result = LocationParser.parseLocations(locations);
+ final result = LocationParser.parseLocations(locations);
- expect(result.length, 1);
+ expect(result.length, 1);
- final marker = result[0];
- expect(marker.point.latitude, 40.7128);
- expect(marker.point.longitude, -74.0060);
- expect(marker.width, 50);
- expect(marker.height, 50);
+ final marker = result[0];
+ expect(marker.point.latitude, 40.7128);
+ expect(marker.point.longitude, -74.0060);
+ expect(marker.width, 50);
+ expect(marker.height, 50);
- // Actually build the widget to ensure it's instantiated
- await tester.pumpWidget(
- MaterialApp(
- home: Scaffold(
- body: marker.child,
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: marker.child,
+ ),
),
- ),
+ );
+
+ expect(find.byType(MapMarker), findsOneWidget);
+ });
+
+ test('parseLocations constructs complete Marker with all properties', () {
+ final locations = [
+ {'Latitude': 40.7128, 'Longitude': -74.0060},
+ ];
+
+ final result = LocationParser.parseLocations(locations);
+ final marker = result[0];
+
+ expect(marker.point, isA());
+ expect(marker.point.latitude, 40.7128);
+ expect(marker.point.longitude, -74.0060);
+ expect(marker.width, 50.0);
+ expect(marker.height, 50.0);
+ expect(marker.child, isA());
+ expect(marker.point.latitude, isA());
+ expect(marker.point.longitude, isA());
+ });
+ });
+
+ group('LocationParser - Geolocator Methods', () {
+ late MockGeolocatorPlatform mockGeolocator;
+ late LocationParser locationParser;
+
+ setUp(() {
+ mockGeolocator = MockGeolocatorPlatform();
+ locationParser = LocationParser(geolocator: mockGeolocator);
+ });
+
+ group('determinePosition', () {
+ test('returns position string when permission is already granted',
+ () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final result = await locationParser.determinePosition();
+
+ expect(result, contains('Latitude: 37.7749'));
+ expect(result, contains('Longitude: -122.4194'));
+ verify(() => mockGeolocator.checkPermission()).called(1);
+ verify(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).called(1);
+ verifyNever(() => mockGeolocator.requestPermission());
+ });
+
+ test('requests and grants permission when initially denied', () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
);
- expect(find.byType(MapMarker), findsOneWidget);
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.denied);
+ when(() => mockGeolocator.requestPermission())
+ .thenAnswer((_) async => LocationPermission.whileInUse);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final result = await locationParser.determinePosition();
+
+ expect(result, contains('Latitude: 37.7749'));
+ verify(() => mockGeolocator.checkPermission()).called(1);
+ verify(() => mockGeolocator.requestPermission()).called(1);
+ verify(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).called(1);
+ });
+
+ test('handles whileInUse permission', () async {
+ final mockPosition = Position(
+ latitude: 40.7128,
+ longitude: -74.0060,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 15.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.whileInUse);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final result = await locationParser.determinePosition();
+
+ expect(result, contains('Latitude: 40.7128'));
+ expect(result, contains('Longitude: -74.006'));
});
});
- });
- test('parseLocations constructs complete Marker with all properties', () {
- final locations = [
- {'Latitude': 40.7128, 'Longitude': -74.0060},
- ];
-
- final result = LocationParser.parseLocations(locations);
- final marker = result[0];
-
- // Verify each property to ensure the constructor was called
- expect(marker.point, isA());
- expect(marker.point.latitude, 40.7128);
- expect(marker.point.longitude, -74.0060);
- expect(marker.width, 50.0);
- expect(marker.height, 50.0);
- expect(marker.child, isA());
-
- // Verify toDouble() conversion happened correctly
- expect(marker.point.latitude, isA());
- expect(marker.point.longitude, isA());
+ group('parseCurrentLocation', () {
+ test('correctly parses position string into coordinate list', () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final coords = await locationParser.parseCurrentLocation();
+
+ expect(coords.length, equals(2));
+ expect(coords[0], equals(37.7749));
+ expect(coords[1], equals(-122.4194));
+ });
+
+ test('handles negative coordinates correctly', () async {
+ final mockPosition = Position(
+ latitude: -33.8688,
+ longitude: 151.2093,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final coords = await locationParser.parseCurrentLocation();
+
+ expect(coords[0], equals(-33.8688));
+ expect(coords[1], equals(151.2093));
+ });
+
+ test('returns list of doubles', () async {
+ final mockPosition = Position(
+ latitude: 51.5074,
+ longitude: -0.1278,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final coords = await locationParser.parseCurrentLocation();
+
+ expect(coords[0], isA());
+ expect(coords[1], isA());
+ });
+ });
+
+ group('getNearestLocation', () {
+ test('returns nearest location from multiple options', () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final locations = [
+ {
+ 'Name': 'Far Location',
+ 'Latitude': 37.7849,
+ 'Longitude': -122.4094,
+ },
+ {
+ 'Name': 'Nearest Location',
+ 'Latitude': 37.7759,
+ 'Longitude': -122.4184,
+ },
+ {
+ 'Name': 'Distant Location',
+ 'Latitude': 37.8049,
+ 'Longitude': -122.3994,
+ },
+ ];
+
+ final nearest = await locationParser.getNearestLocation(locations);
+
+ expect(nearest, isNotNull);
+ expect(nearest!['Name'], equals('Nearest Location'));
+ expect(nearest['Latitude'], equals(37.7759));
+ expect(nearest['Longitude'], equals(-122.4184));
+ });
+
+ test('returns null when locations list is empty', () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final nearest = await locationParser.getNearestLocation([]);
+
+ expect(nearest, isNull);
+ });
+
+ test('skips locations without valid coordinates', () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final locations = [
+ {
+ 'Name': 'Invalid - No coords',
+ },
+ {
+ 'Name': 'Invalid - Only Lat',
+ 'Latitude': 37.7759,
+ },
+ {
+ 'Name': 'Valid Location',
+ 'Latitude': 37.7759,
+ 'Longitude': -122.4184,
+ },
+ ];
+
+ final nearest = await locationParser.getNearestLocation(locations);
+
+ expect(nearest, isNotNull);
+ expect(nearest!['Name'], equals('Valid Location'));
+ });
+
+ test('handles single location in list', () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final locations = [
+ {
+ 'Name': 'Only Location',
+ 'Latitude': 37.7849,
+ 'Longitude': -122.4094,
+ },
+ ];
+
+ final nearest = await locationParser.getNearestLocation(locations);
+
+ expect(nearest, isNotNull);
+ expect(nearest!['Name'], equals('Only Location'));
+ });
+
+ test('returns null when all locations have invalid coordinates',
+ () async {
+ final mockPosition = Position(
+ latitude: 37.7749,
+ longitude: -122.4194,
+ timestamp: DateTime(2024, 1, 1),
+ accuracy: 10.0,
+ altitude: 0.0,
+ heading: 0.0,
+ speed: 0.0,
+ speedAccuracy: 0.0,
+ altitudeAccuracy: 0.0,
+ headingAccuracy: 0.0,
+ );
+
+ when(() => mockGeolocator.checkPermission())
+ .thenAnswer((_) async => LocationPermission.always);
+ when(() => mockGeolocator.getCurrentPosition(
+ locationSettings: any(named: 'locationSettings'),
+ )).thenAnswer((_) async => mockPosition);
+
+ final locations = [
+ {'Name': 'No coords'},
+ {'Name': 'Only Lat', 'Latitude': 37.7759},
+ {'Name': 'Only Lng', 'Longitude': -122.4184},
+ ];
+
+ final nearest = await locationParser.getNearestLocation(locations);
+
+ expect(nearest, isNull);
+ });
+ });
});
-}
\ No newline at end of file
+}
diff --git a/test/logic/parsing/mocks.dart b/test/logic/parsing/mocks.dart
new file mode 100644
index 00000000..d715ebbd
--- /dev/null
+++ b/test/logic/parsing/mocks.dart
@@ -0,0 +1,5 @@
+// test/mocks/mock_geolocator.dart
+import 'package:geolocator/geolocator.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {}
\ No newline at end of file
diff --git a/test/logic/parsing/transaction_parser_test.dart b/test/logic/parsing/transaction_parser_test.dart
index 02773b84..e269b3a2 100644
--- a/test/logic/parsing/transaction_parser_test.dart
+++ b/test/logic/parsing/transaction_parser_test.dart
@@ -10,7 +10,7 @@ void main(){
test("Test that transactions are parsed correctly",(){
final result = TransactionParser.formatTransaction(({"amount": 2.75, "description": "Dryer", "created_at": DateTime.now().toString()}), "transactionHistory");
- expect(result, "\$2.75 used on Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}");
+ expect(result, "\$2.75 - Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}");
});
test("Test that transactions are parsed correctly if description is Loyalty Card",(){
@@ -21,7 +21,7 @@ void main(){
test("Test that it can format a list of transactions",(){
final result = TransactionParser.formatTransactionsList([{"amount": 2.75, "description": "Dryer", "created_at": DateTime.now().toString()},{"amount": 4.75, "description": "Washer", "created_at": "2025-11-12T19:23:24.781326+00:00"}], "transactionHistory");
- expect(result[0], "\$2.75 used on Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}");
+ expect(result[0], "\$2.75 - Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}");
});
test("Test for monthly report",(){
diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart
index eba1a76f..fbe7ce24 100644
--- a/test/logic/payment/process_payment_test.dart
+++ b/test/logic/payment/process_payment_test.dart
@@ -1,3 +1,5 @@
+import 'package:clean_stream_laundry_app/logic/services/auth_service.dart';
+import 'package:clean_stream_laundry_app/logic/services/profile_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:clean_stream_laundry_app/logic/services/payment_service.dart';
@@ -6,42 +8,52 @@ import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart';
import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:clean_stream_laundry_app/logic/exceptions/platform_exception.dart';
+import 'package:get_it/get_it.dart';
class MockPaymentService extends Mock implements PaymentService {}
class MockTransactionService extends Mock implements TransactionService {}
+class MockAuthService extends Mock implements AuthService {}
+
+class MockProfileService extends Mock implements ProfileService {}
+
void main() {
late MockPaymentService mockPaymentService;
late MockTransactionService mockTransactionService;
late PaymentProcessor paymentProcessor;
+ late MockAuthService mockAuthService;
+ late MockProfileService mockProfileService;
setUp(() {
mockPaymentService = MockPaymentService();
mockTransactionService = MockTransactionService();
+ mockAuthService = MockAuthService();
+ mockProfileService = MockProfileService();
+
+ final getIt = GetIt.instance;
+ getIt.reset();
+ getIt.registerSingleton(mockPaymentService);
+ getIt.registerSingleton(mockTransactionService);
+ getIt.registerSingleton(mockAuthService);
+ getIt.registerSingleton(mockProfileService);
+
+ paymentProcessor = PaymentProcessor();
- paymentProcessor = PaymentProcessor(
- paymentService: mockPaymentService,
- transactionService: mockTransactionService,
- );
});
group('PaymentProcessor.processPayment', () {
test('should complete payment and record transaction on success', () async {
- // Arrange
const amount = 100.0;
const description = 'Test payment';
- when(
- () => mockPaymentService.makePayment(amount),
- ).thenAnswer((_) async => Future.value());
- when(
- () => mockTransactionService.recordTransaction(
- amount: amount,
- description: description,
- type: 'Laundry',
- ),
- ).thenAnswer((_) async => {});
+ when(() => mockPaymentService.makePayment(amount))
+ .thenAnswer((_) async => Future.value());
+ when(() => mockTransactionService.recordTransaction(
+ amount: any(named: 'amount'),
+ description: any(named: 'description'),
+ type: any(named: 'type'),
+ )).thenAnswer((_) async => {});
// Act
final result = await paymentProcessor.processPayment(amount, description);
@@ -49,13 +61,11 @@ void main() {
// Assert
expect(result, PaymentResult.success);
verify(() => mockPaymentService.makePayment(amount)).called(1);
- verify(
- () => mockTransactionService.recordTransaction(
- amount: amount,
- description: description,
- type: 'Laundry',
- ),
- ).called(1);
+ verify(() => mockTransactionService.recordTransaction(
+ amount: amount,
+ description: description,
+ type: 'Laundry',
+ )).called(1);
});
test(
diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart
index a148bdd0..d5ea4845 100644
--- a/test/logic/viewmodels/loyalty_view_model_test.dart
+++ b/test/logic/viewmodels/loyalty_view_model_test.dart
@@ -7,6 +7,7 @@ import 'package:clean_stream_laundry_app/logic/services/profile_service.dart';
import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart';
import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart';
import 'mocks.dart';
+import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart';
void main() {
late LoyaltyViewModel viewModel;
@@ -46,10 +47,10 @@ void main() {
// Arrange
when(() => mockAuthService.getCurrentUserId).thenReturn('user123');
when(
- () => mockProfileService.getUserBalanceById('user123'),
+ () => mockProfileService.getUserBalanceById('user123'),
).thenAnswer((_) async => {'balance': 100.0, 'full_name': 'Jane Doe'});
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
// Act
@@ -71,10 +72,10 @@ void main() {
// Arrange
when(() => mockAuthService.getCurrentUserId).thenReturn('user123');
when(
- () => mockProfileService.getUserBalanceById('user123'),
+ () => mockProfileService.getUserBalanceById('user123'),
).thenThrow(Exception('Network error'));
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
// Act
@@ -85,14 +86,30 @@ void main() {
expect(viewModel.isLoading, false);
});
+ test('initialize should handle null userId', () async {
+ // Arrange
+ when(() => mockAuthService.getCurrentUserId).thenReturn(null);
+ when(() => mockTransactionService.getTransactionsForUser())
+ .thenAnswer((_) async => []);
+
+ // Act
+ await viewModel.initialize();
+
+ // Assert
+ expect(viewModel.errorMessage, 'User not known');
+ expect(viewModel.isLoading, false);
+
+ verifyNever(() => mockProfileService.getUserBalanceById(any()));
+ });
+
test('should default to 0.0 balance when null', () async {
// Arrange
when(() => mockAuthService.getCurrentUserId).thenReturn('user123');
when(
- () => mockProfileService.getUserBalanceById('user123'),
+ () => mockProfileService.getUserBalanceById('user123'),
).thenAnswer((_) async => {'balance': null, 'full_name': 'Jane Doe'});
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
// Act
@@ -107,10 +124,10 @@ void main() {
// Arrange
when(() => mockAuthService.getCurrentUserId).thenReturn('user123');
when(
- () => mockProfileService.getUserBalanceById('user123'),
+ () => mockProfileService.getUserBalanceById('user123'),
).thenAnswer((_) async => {'balance': 100.0, 'full_name': null});
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
// Act
@@ -125,7 +142,7 @@ void main() {
test('should toggle showPastTransactions from false to true', () async {
// Arrange
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
expect(viewModel.showPastTransactions, false);
@@ -141,7 +158,7 @@ void main() {
test('should toggle showPastTransactions from true to false', () async {
// Arrange
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
viewModel.showPastTransactions = true;
@@ -158,7 +175,7 @@ void main() {
test('should call transaction service', () async {
// Arrange
when(
- () => mockTransactionService.getTransactionsForUser(),
+ () => mockTransactionService.getTransactionsForUser(),
).thenAnswer((_) async => []);
// Act
@@ -167,17 +184,96 @@ void main() {
// Assert
verify(() => mockTransactionService.getTransactionsForUser()).called(1);
});
+
+ test('fetchTransactions filters out Rewards and old transactions', () async {
+ // Arrange
+ final now = DateTime.now();
+ when(() => mockTransactionService.getTransactionsForUser()).thenAnswer(
+ (_) async => [
+ {
+ 'created_at': now.toIso8601String(),
+ 'type': 'Laundry',
+ 'amount': 10,
+ 'description': 'Wash',
+ },
+ {
+ 'created_at': now.toIso8601String(),
+ 'type': 'Rewards',
+ 'amount': 1,
+ 'description': 'Reward',
+ },
+ {
+ 'created_at':
+ now.subtract(const Duration(days: 40)).toIso8601String(),
+ 'type': 'Laundry',
+ 'amount': 5,
+ 'description': 'Old wash',
+ },
+ ],
+ );
+
+ // Act
+ await viewModel.toggleTransactionView();
+
+ // Assert
+ expect(viewModel.recentTransactions.length, 1);
+ });
});
group('loadCard', () {
- // Note: This test is incomplete because processPayment is a top-level function
- // See options below for how to handle this
- test(
- 'should update balance and fetch transactions on successful payment',
- () async {
- // You'll need to refactor processPayment to be testable
- // See suggestions below
- },
- );
+ test('loadCard should update balance and fetch transactions on success',
+ () async {
+ // Arrange
+ when(() => mockAuthService.getCurrentUserId).thenReturn('user123');
+
+ viewModel.userBalance = 20.0;
+
+ when(() => mockPaymentProcessor.processPayment(
+ 10.0,
+ 'Loyalty Card',
+ )).thenAnswer((_) async => PaymentResult.success);
+
+ when(() => mockProfileService.updateBalanceById('user123', 30))
+ .thenAnswer((_) async => Future.value());
+
+ // Stub for checkRewards -> updateRewardsById called internally
+ when(() => mockProfileService.updateRewardsById(any(), any()))
+ .thenAnswer((_) async => Future.value());
+
+ when(() => mockTransactionService.getTransactionsForUser())
+ .thenAnswer((_) async => []);
+
+ // Act
+ final result = await viewModel.loadCard(10.0);
+
+ // Assert
+ expect(result, PaymentResult.success);
+ expect(viewModel.userBalance, 30);
+
+ verify(() => mockProfileService.updateBalanceById('user123', 30))
+ .called(1);
+ verify(() => mockTransactionService.getTransactionsForUser()).called(1);
+ });
+
+ test('loadCard should not update balance on failed payment', () async {
+ // Arrange
+ when(() => mockAuthService.getCurrentUserId).thenReturn('user123');
+
+ viewModel.userBalance = 20.0;
+
+ when(() => mockPaymentProcessor.processPayment(
+ 10.0,
+ 'Loyalty Card',
+ )).thenAnswer((_) async => PaymentResult.failed);
+
+ // Act
+ final result = await viewModel.loadCard(10.0);
+
+ // Assert
+ expect(result, PaymentResult.failed);
+ expect(viewModel.userBalance, 20.0);
+
+ verifyNever(() => mockProfileService.updateBalanceById(any(), any()));
+ });
});
-}
+}
\ No newline at end of file
diff --git a/test/pages/edit_profile_page_test.dart b/test/pages/edit_profile_page_test.dart
index 0a1b3b68..3091168a 100644
--- a/test/pages/edit_profile_page_test.dart
+++ b/test/pages/edit_profile_page_test.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart';
import 'package:clean_stream_laundry_app/logic/services/auth_service.dart';
+import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart';
import 'package:clean_stream_laundry_app/logic/services/profile_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -12,11 +13,13 @@ import 'mocks.dart';
void main() {
late MockAuthService authService;
late MockProfileService profileService;
+ late MockEdgeFunctionService edgeFunctionService;
late StreamController authController;
setUp(() {
authService = MockAuthService();
profileService = MockProfileService();
+ edgeFunctionService = MockEdgeFunctionService();
authController = StreamController.broadcast();
final getIt = GetIt.instance;
@@ -24,15 +27,16 @@ void main() {
getIt.registerSingleton(authService);
getIt.registerSingleton(profileService);
+ getIt.registerSingleton(edgeFunctionService);
when(() => authService.onAuthChange)
.thenAnswer((_) => authController.stream);
when(() => authService.getCurrentUserId)
- .thenAnswer((_) => 'user-id');
+ .thenAnswer((_) => 'user-id');
when(() => authService.getCurrentUserEmail())
- .thenAnswer((_) => 'test@example.com');
+ .thenAnswer((_) => 'test@example.com');
when(() => profileService.getUserNameById('user-id'))
.thenAnswer((_) async => 'John Doe');
@@ -65,18 +69,45 @@ void main() {
builder: (_, __) =>
const Scaffold(body: Text('Verify Email')),
),
+ GoRoute(
+ path: '/login',
+ builder: (_, __) => const Scaffold(body: Text('Login')),
+ ),
],
),
);
}
- testWidgets('loads and displays user data', (tester) async {
+ testWidgets('displays page title', (tester) async {
await tester.pumpWidget(createWidget());
await tester.pumpAndSettle();
- expect(find.text('Current Name: John Doe'), findsOneWidget);
- expect(find.text('Current Email: test@example.com'), findsOneWidget);
+ expect(find.text('Edit Profile'), findsOneWidget);
+ });
+
+ testWidgets('loads and displays user data in info cards', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Full Name'), findsOneWidget);
+ expect(find.text('Email Address'), findsOneWidget);
+
+ expect(find.text('Current'), findsNWidgets(2));
+ expect(find.text('John Doe'), findsNWidgets(2));
+ expect(find.text('test@example.com'), findsNWidgets(2));
+
expect(find.text('Save Changes'), findsOneWidget);
+ expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);
+ });
+
+ testWidgets('displays danger zone section with delete account button', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Danger Zone'), findsOneWidget);
+ expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);
+ expect(find.text('Delete Account'), findsOneWidget);
+ expect(find.byIcon(Icons.delete_outline), findsOneWidget);
});
testWidgets('shows No Changes dialog if nothing changed', (tester) async {
@@ -87,7 +118,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('No Changes'), findsOneWidget);
- expect(find.text('You haven’t changed anything.'), findsOneWidget);
+ expect(find.text('You haven\'t changed anything.'), findsOneWidget);
verifyNever(() => authService.updateUserAttributes(
email: any(named: 'email'),
@@ -99,27 +130,45 @@ void main() {
await tester.pumpWidget(createWidget());
await tester.pumpAndSettle();
- await tester.enterText(
- find.widgetWithText(TextFormField, 'Full Name'), '');
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+ await tester.enterText(nameField, '');
+
await tester.tap(find.text('Save Changes'));
await tester.pumpAndSettle();
- await tester.tap(find.text('Yes, Save'));
+ await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Name cannot be empty'), findsOneWidget);
});
+ testWidgets('validates invalid email', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ final emailField = find.widgetWithText(TextFormField, 'New Email');
+ await tester.enterText(emailField, 'invalid-email');
+
+ await tester.tap(find.text('Save Changes'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Save'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Please enter a valid email'), findsOneWidget);
+ });
+
testWidgets('updates name only', (tester) async {
await tester.pumpWidget(createWidget());
await tester.pumpAndSettle();
- await tester.enterText(
- find.widgetWithText(TextFormField, 'Full Name'), 'Jane Smith');
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+ await tester.enterText(nameField, 'Jane Smith');
await tester.tap(find.text('Save Changes'));
await tester.pumpAndSettle();
- await tester.tap(find.text('Yes, Save'));
+
+ await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
verify(() => authService.updateUserAttributes(
@@ -134,12 +183,13 @@ void main() {
await tester.pumpWidget(createWidget());
await tester.pumpAndSettle();
- await tester.enterText(
- find.widgetWithText(TextFormField, 'Email'), 'new@email.com');
+ final emailField = find.widgetWithText(TextFormField, 'New Email');
+ await tester.enterText(emailField, 'new@email.com');
await tester.tap(find.text('Save Changes'));
await tester.pumpAndSettle();
- await tester.tap(find.text('Yes, Save'));
+
+ await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
verify(() => authService.updateUserAttributes(
@@ -154,14 +204,16 @@ void main() {
await tester.pumpWidget(createWidget());
await tester.pumpAndSettle();
- await tester.enterText(
- find.widgetWithText(TextFormField, 'Full Name'), ' Jane ');
- await tester.enterText(
- find.widgetWithText(TextFormField, 'Email'), ' jane@email.com ');
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+ final emailField = find.widgetWithText(TextFormField, 'New Email');
+
+ await tester.enterText(nameField, ' Jane ');
+ await tester.enterText(emailField, ' jane@email.com ');
await tester.tap(find.text('Save Changes'));
await tester.pumpAndSettle();
- await tester.tap(find.text('Yes, Save'));
+
+ await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
verify(() => authService.updateUserAttributes(
@@ -179,4 +231,181 @@ void main() {
expect(find.text('Settings'), findsOneWidget);
});
-}
+
+ testWidgets('shows confirmation dialog before saving changes', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+ await tester.enterText(nameField, 'New Name');
+
+ await tester.tap(find.text('Save Changes'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Confirm Changes'), findsOneWidget);
+ expect(find.text('Are you sure you want to save these changes to your profile?'), findsOneWidget);
+ expect(find.text('Cancel'), findsOneWidget);
+ expect(find.text('Save'), findsOneWidget);
+ });
+
+ testWidgets('cancels save when user clicks cancel in confirmation', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+ await tester.enterText(nameField, 'New Name');
+
+ await tester.tap(find.text('Save Changes'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+
+ verifyNever(() => authService.updateUserAttributes(
+ email: any(named: 'email'),
+ data: any(named: 'data'),
+ ));
+ });
+
+ testWidgets('shows delete account confirmation dialog', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500));
+ await tester.pumpAndSettle();
+
+ final deleteButton = find.byType(OutlinedButton);
+ await tester.tap(deleteButton);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Delete Account?'), findsOneWidget);
+ expect(find.text('Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.'), findsOneWidget);
+ expect(find.text('Cancel'), findsOneWidget);
+ expect(find.text('Delete'), findsOneWidget);
+ });
+
+ testWidgets('cancels delete when user clicks cancel', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500));
+ await tester.pumpAndSettle();
+
+ final deleteButton = find.byType(OutlinedButton);
+ await tester.tap(deleteButton);
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+
+ verifyNever(() => edgeFunctionService.runEdgeFunction(
+ name: any(named: 'name'),
+ body: any(named: 'body'),
+ ));
+ });
+
+ testWidgets('input fields have proper hint text', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Enter your full name'), findsOneWidget);
+ expect(find.text('Enter your email address'), findsOneWidget);
+ });
+
+ testWidgets('displays loading indicator while fetching data', (tester) async {
+ final completer = Completer();
+
+ when(() => profileService.getUserNameById('user-id'))
+ .thenAnswer((_) => completer.future);
+
+ await tester.pumpWidget(createWidget());
+ await tester.pump();
+
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
+ completer.complete('John Doe');
+ await tester.pumpAndSettle();
+
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ expect(find.text('John Doe'), findsNWidgets(2));
+ });
+
+ testWidgets('disables inputs while saving', (tester) async {
+ final completer = Completer();
+
+ when(() => authService.updateUserAttributes(
+ email: any(named: 'email'),
+ data: any(named: 'data'),
+ )).thenAnswer((_) => completer.future);
+
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+ await tester.enterText(nameField, 'New Name');
+
+ await tester.tap(find.text('Save Changes'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Save'));
+ await tester.pump();
+
+ final saveButton = tester.widget(
+ find.ancestor(
+ of: find.byType(CircularProgressIndicator),
+ matching: find.byType(ElevatedButton),
+ ).first,
+ );
+ expect(saveButton.onPressed, isNull);
+
+ completer.complete();
+ await tester.pumpAndSettle();
+ });
+
+ testWidgets('enforces name character limit', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+
+ const longName = 'This is a very long name that exceeds the limit';
+ await tester.enterText(nameField, longName);
+ await tester.pump();
+
+ final textField = tester.widget(nameField);
+ final controller = textField.controller!;
+
+ expect(controller.text.length, lessThanOrEqualTo(36));
+ });
+
+ testWidgets('name field only allows alphanumeric and spaces', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ final nameField = find.widgetWithText(TextFormField, 'New Full Name');
+
+ await tester.enterText(nameField, 'Test@#\$%');
+ await tester.pump();
+
+ final textField = tester.widget(nameField);
+ final controller = textField.controller!;
+
+ expect(controller.text, 'Test');
+ });
+
+ testWidgets('displays icon buttons in danger zone', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);
+
+ expect(find.byIcon(Icons.delete_outline), findsOneWidget);
+ });
+
+ testWidgets('page is scrollable', (tester) async {
+ await tester.pumpWidget(createWidget());
+ await tester.pumpAndSettle();
+
+ expect(find.byType(SingleChildScrollView), findsOneWidget);
+ });
+}
\ No newline at end of file
diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart
index dd0c7abc..88a0bee2 100644
--- a/test/pages/home_page_test.dart
+++ b/test/pages/home_page_test.dart
@@ -81,13 +81,6 @@ void main() {
.thenAnswer((_) async => idleDryers);
}
- Future selectLocation(WidgetTester tester, String address) async {
- await tester.tap(find.byType(DropdownButton));
- await tester.pumpAndSettle();
- await tester.tap(find.text(address).last);
- await tester.pumpAndSettle();
- }
-
group('HomePage Widget Tests', () {
test('should create HomePageState', () {
const homePage = HomePage();
@@ -129,21 +122,19 @@ void main() {
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
- await tester.pumpAndSettle(const Duration(seconds: 1));
-
- final dropdownFinder = find.descendant(
- of: find.byType(DropdownButtonHideUnderline),
- matching: find.byType(DropdownButton),
- );
+ // Find and tap the button that opens the BottomSheet
+ final openSheetButton = find.text('Select Location');
+ expect(openSheetButton, findsOneWidget);
- expect(dropdownFinder, findsOneWidget);
+ await tester.tap(openSheetButton);
+ await tester.pumpAndSettle();
- final dropdown = tester.widget>(dropdownFinder);
- expect(dropdown.items, isNotNull);
- expect(dropdown.items!.length, equals(2));
+ // Now the BottomSheet should be visible with your location items
+ final location1 = find.text('123 Main St');
+ final location2 = find.text('456 Oak Ave');
- expect(dropdown.items![0].value, equals('123 Main St'));
- expect(dropdown.items![1].value, equals('456 Oak Ave'));
+ expect(location1, findsOneWidget);
+ expect(location2, findsOneWidget);
});
testWidgets('should restore last selected location from storage', (tester) async {
@@ -177,6 +168,141 @@ void main() {
});
});
+
+ group('Nearest Location Button', () {
+ testWidgets('should find and select nearest location when button is tapped', (tester) async {
+ final testLocations = [
+ {
+ "id": 1,
+ "Address": "123 Main St",
+ "Latitude": 40.0,
+ "Longitude": -86.0,
+ },
+ {
+ "id": 2,
+ "Address": "456 Oak Ave",
+ "Latitude": 40.5,
+ "Longitude": -86.5,
+ },
+ ];
+
+ mockLocations(testLocations);
+ mockMachineCounts('1');
+
+ await tester.pumpWidget(createWidgetUnderTest());
+ await tester.pumpAndSettle();
+
+ final nearestLocationButton = find.ancestor(
+ of: find.text('Nearest Location'),
+ matching: find.byType(InkWell),
+ );
+ expect(nearestLocationButton, findsOneWidget);
+
+ await tester.tap(nearestLocationButton);
+ await tester.pumpAndSettle();
+
+ verify(() => mockLocationService.getLocations()).called(greaterThan(1));
+ });
+
+ testWidgets('should display nearest location button with correct styling', (tester) async {
+ mockLocations([{"id": 1, "Address": "123 Main St"}]);
+ await tester.pumpWidget(createWidgetUnderTest());
+ await tester.pumpAndSettle();
+
+ final button = find.ancestor(
+ of: find.text('Nearest Location'),
+ matching: find.byType(InkWell),
+ );
+ expect(button, findsOneWidget);
+
+ final inkWell = tester.widget(button);
+ expect(inkWell.onTap, isNotNull);
+
+ expect(find.text('Nearest Location'), findsOneWidget);
+ });
+
+ testWidgets('should update selected location after finding nearest', (tester) async {
+ final testLocations = [
+ {
+ "id": 1,
+ "Address": "123 Main St",
+ "Latitude": 40.0,
+ "Longitude": -86.0,
+ },
+ {
+ "id": 2,
+ "Address": "456 Oak Ave",
+ "Latitude": 40.5,
+ "Longitude": -86.5,
+ },
+ ];
+
+ mockLocations(testLocations);
+ mockMachineCounts('1');
+ mockMachineCounts('2');
+
+ await tester.pumpWidget(createWidgetUnderTest());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Select Location'), findsOneWidget);
+
+ final nearestLocationButton = find.ancestor(
+ of: find.text('Nearest Location'),
+ matching: find.byType(InkWell),
+ );
+ await tester.tap(nearestLocationButton);
+ await tester.pumpAndSettle();
+
+ });
+
+ testWidgets('should save selected location to storage', (tester) async {
+ final testLocations = [
+ {
+ "id": 1,
+ "Address": "123 Main St",
+ "Latitude": 40.0,
+ "Longitude": -86.0,
+ },
+ ];
+
+ mockLocations(testLocations);
+ mockMachineCounts('1');
+
+ await tester.pumpWidget(createWidgetUnderTest());
+ await tester.pumpAndSettle();
+
+ final nearestLocationButton = find.ancestor(
+ of: find.text('Nearest Location'),
+ matching: find.byType(InkWell),
+ );
+ await tester.tap(nearestLocationButton);
+ await tester.pumpAndSettle();
+ });
+ });
+
+ group("Tests navigation button", (){
+
+ testWidgets('Tests that icon button is visible', (tester) async {
+
+ final testLocations = [
+ {
+ "id": 1,
+ "Address": "123 Main St",
+ "Latitude": 40.0,
+ "Longitude": -86.0,
+ },
+ ];
+
+ mockLocations(testLocations);
+ mockMachineCounts('1');
+
+ await tester.pumpWidget(createWidgetUnderTest());
+ await tester.pumpAndSettle();
+
+ expect(find.byType(IconButton), findsOneWidget);
+ });
+ });
+
});
}
\ No newline at end of file
diff --git a/test/pages/loading_page_test.dart b/test/pages/loading_page_test.dart
index e9c227eb..3bee029f 100644
--- a/test/pages/loading_page_test.dart
+++ b/test/pages/loading_page_test.dart
@@ -251,7 +251,7 @@ void main() {
name: any(named: 'name'),
)).thenAnswer((_) async {});
- when(() => mockAuthService.handleOAuthRedirect(any()))
+ when(() => mockAuthService.getSessionFromURI(any()))
.thenAnswer((_) async {});
await tester.pumpWidget(createTestWidget(LoadingPage()));
diff --git a/test/pages/login_page_test.dart b/test/pages/login_page_test.dart
index 1b9eceb1..d9c6469d 100644
--- a/test/pages/login_page_test.dart
+++ b/test/pages/login_page_test.dart
@@ -389,7 +389,7 @@ void main() {
await tester.pumpAndSettle();
when(
- () => mockAuthService.handleOAuthRedirect(any()),
+ () => mockAuthService.getSessionFromURI(any()),
).thenAnswer((_) async {});
when(
() => mockAuthService.isLoggedIn(),
@@ -414,7 +414,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Home Page'), findsOneWidget);
- verify(() => mockAuthService.handleOAuthRedirect(any())).called(1);
+ verify(() => mockAuthService.getSessionFromURI(any())).called(1);
verify(() => mockAuthService.isLoggedIn()).called(1);
verify(() => mockAuthService.getCurrentUser()).called(1);
verify(
diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart
index 957ae99b..255f39af 100644
--- a/test/pages/loyalty_card_page_test.dart
+++ b/test/pages/loyalty_card_page_test.dart
@@ -12,6 +12,22 @@ import 'mocks.dart';
void main() {
late MockLoyaltyViewModel mockViewModel;
+ const singleTransaction = 'Test transaction';
+ const firstTransaction = 'Test transaction 1';
+ const secondTransaction = 'Test transaction 2';
+ const thirdTransaction = 'Test transaction 3';
+ const transactionHistory = [
+ firstTransaction,
+ secondTransaction,
+ thirdTransaction,
+ ];
+
+ Finder findTransactionScrollable() {
+ return find.descendant(
+ of: find.byType(ListView),
+ matching: find.byType(Scrollable),
+ );
+ }
setUpAll(() {
// Register fallback values for mocktail
@@ -129,7 +145,7 @@ void main() {
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.text('Current Balance: \$42.75'), findsOneWidget);
+ expect(find.text('Loyalty Balance: \$42.75'), findsOneWidget);
});
testWidgets('should display balance with two decimal places', (
@@ -140,7 +156,7 @@ void main() {
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.text('Current Balance: \$100.00'), findsOneWidget);
+ expect(find.text('Loyalty Balance: \$100.00'), findsOneWidget);
});
testWidgets('should display default balance when userBalance is null', (
@@ -151,7 +167,7 @@ void main() {
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.text('Current Balance: \$0.00'), findsOneWidget);
+ expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget);
});
testWidgets('should display Load card button with correct styling', (
@@ -168,13 +184,6 @@ void main() {
expect(button.onPressed, isNotNull);
});
-
- testWidgets('should have scrollable content', (tester) async {
- await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
- await tester.pump();
-
- expect(find.byType(SingleChildScrollView), findsOneWidget);
- });
});
group('Transactions Display', () {
@@ -195,7 +204,7 @@ void main() {
) async {
when(
() => mockViewModel.recentTransactions,
- ).thenReturn(['Transaction 1']);
+ ).thenReturn([singleTransaction]);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
@@ -204,18 +213,23 @@ void main() {
});
testWidgets('should display all transactions in list', (tester) async {
- when(() => mockViewModel.recentTransactions).thenReturn([
- 'Loaded \$10.00 on 01/10/2025',
- 'Used \$2.50 on 01/09/2025',
- 'Loaded \$25.00 on 01/08/2025',
- ]);
+ when(
+ () => mockViewModel.recentTransactions,
+ ).thenReturn(transactionHistory);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
- await tester.pump();
+ await tester.pumpAndSettle();
+
+ expect(find.text(firstTransaction, skipOffstage: false), findsOneWidget);
- expect(find.text('Loaded \$10.00 on 01/10/2025'), findsOneWidget);
- expect(find.text('Used \$2.50 on 01/09/2025'), findsOneWidget);
- expect(find.text('Loaded \$25.00 on 01/08/2025'), findsOneWidget);
+ await tester.scrollUntilVisible(
+ find.text(secondTransaction),
+ 100,
+ scrollable: findTransactionScrollable(),
+ );
+ expect(find.text(secondTransaction), findsOneWidget);
+
+ expect(find.text(thirdTransaction, skipOffstage: false), findsOneWidget);
});
testWidgets(
@@ -223,7 +237,7 @@ void main() {
(tester) async {
when(
() => mockViewModel.recentTransactions,
- ).thenReturn(['Transaction 1']);
+ ).thenReturn([singleTransaction]);
when(() => mockViewModel.showPastTransactions).thenReturn(false);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
@@ -239,7 +253,7 @@ void main() {
(tester) async {
when(
() => mockViewModel.recentTransactions,
- ).thenReturn(['Transaction 1']);
+ ).thenReturn([singleTransaction]);
when(() => mockViewModel.showPastTransactions).thenReturn(true);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
@@ -255,7 +269,7 @@ void main() {
) async {
when(
() => mockViewModel.recentTransactions,
- ).thenReturn(['Transaction 1']);
+ ).thenReturn([singleTransaction]);
when(() => mockViewModel.showPastTransactions).thenReturn(false);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
@@ -272,7 +286,7 @@ void main() {
) async {
when(
() => mockViewModel.recentTransactions,
- ).thenReturn(['Transaction 1']);
+ ).thenReturn([singleTransaction]);
when(() => mockViewModel.showPastTransactions).thenReturn(true);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
@@ -289,13 +303,16 @@ void main() {
) async {
when(
() => mockViewModel.recentTransactions,
- ).thenReturn(['Transaction 1']);
+ ).thenReturn([singleTransaction]);
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.byType(Card), findsWidgets);
- expect(find.byIcon(Icons.receipt_long), findsOneWidget);
+ expect(find.byType(Card, skipOffstage: false), findsWidgets);
+ expect(
+ find.byIcon(Icons.receipt_long, skipOffstage: false),
+ findsOneWidget,
+ );
});
});
@@ -792,7 +809,7 @@ void main() {
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.text('Current Balance: \$0.00'), findsOneWidget);
+ expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget);
});
testWidgets('should handle empty transaction list', (tester) async {
@@ -811,7 +828,7 @@ void main() {
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.text('Current Balance: \$9999.99'), findsOneWidget);
+ expect(find.text('Loyalty Balance: \$9999.99'), findsOneWidget);
});
testWidgets('should handle zero balance', (tester) async {
@@ -820,7 +837,52 @@ void main() {
await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
await tester.pump();
- expect(find.text('Current Balance: \$0.00'), findsOneWidget);
+ expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget);
+ });
+ });
+ group('Reward Info Dialog', () {
+ testWidgets('should display info button next to reward text', (
+ tester,
+ ) async {
+ when(() => mockViewModel.userReward).thenReturn(5.0);
+
+ await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
+ await tester.pump();
+
+ expect(find.byIcon(Icons.info_outline), findsOneWidget);
+ });
+
+ testWidgets('should open reward info dialog when info button is tapped', (
+ tester,
+ ) async {
+ await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
+ await tester.pump();
+
+ await tester.tap(find.byIcon(Icons.info_outline));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Rewards program'), findsOneWidget);
+ expect(
+ find.text(
+ 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.',
+ ),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('should close reward info dialog when Got it is tapped', (
+ tester,
+ ) async {
+ await tester.pumpWidget(createTestWidget(const LoyaltyPage()));
+ await tester.pump();
+
+ await tester.tap(find.byIcon(Icons.info_outline));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Got it'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Rewards program'), findsNothing);
});
});
}
diff --git a/test/pages/mocks.dart b/test/pages/mocks.dart
index afef57cd..a6dee910 100644
--- a/test/pages/mocks.dart
+++ b/test/pages/mocks.dart
@@ -14,6 +14,7 @@ import 'package:clean_stream_laundry_app/logic/services/edge_function_service.da
import 'package:clean_stream_laundry_app/services/notification_service.dart';
import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart';
import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart';
+import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart';
class MockAuthService extends Mock implements AuthService {}
@@ -23,6 +24,8 @@ class MockLocationService extends Mock implements LocationService {}
class MockMachineService extends Mock implements MachineService {}
+class MockDoorUnlocker extends Mock implements DoorUnlocker {}
+
class MockThemeManager extends Mock implements ThemeManager {}
class MockProfileService extends Mock implements ProfileService {}
diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart
index 465adb12..0bd75d28 100644
--- a/test/pages/monthly_transaction_history_test.dart
+++ b/test/pages/monthly_transaction_history_test.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
+import 'package:intl/intl.dart';
import 'mocks.dart';
void main() {
@@ -27,9 +28,7 @@ void main() {
getIt.registerSingleton