diff --git a/lib/features/change_email_verification/change_email_verification.dart b/lib/features/change_email_verification/change_email_verification.dart new file mode 100644 index 0000000..d14f6c3 --- /dev/null +++ b/lib/features/change_email_verification/change_email_verification.dart @@ -0,0 +1,81 @@ +import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:app_links/app_links.dart'; +import 'controller.dart'; +import 'widgets/resend_verification.dart'; + +class ChangeEmailVerificationPage extends StatefulWidget { + final AppLinks appLinks; + + const ChangeEmailVerificationPage({super.key, required this.appLinks}); + + @override + State createState() => + _ChangeEmailVerificationPageState(); +} + +class _ChangeEmailVerificationPageState + extends State { + late final ChangeEmailVerificationController _controller; + + @override + void initState() { + super.initState(); + _controller = ChangeEmailVerificationController( + appLinks: widget.appLinks, + context: context, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _refresh() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.email, size: 80, color: Colors.blueAccent), + const SizedBox(height: 24), + Text( + 'Please verify your new email address', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + const SizedBox(height: 16), + Text( + 'Check your new email\'s inbox and click the verification link.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + const SizedBox(height: 24), + ResendVerificationWidget( + controller: _controller, + onStateChange: _refresh, + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/change_email_verification/controller.dart b/lib/features/change_email_verification/controller.dart new file mode 100644 index 0000000..e1b24bc --- /dev/null +++ b/lib/features/change_email_verification/controller.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:app_links/app_links.dart'; + +class ChangeEmailVerificationController { + final AuthService _authService = GetIt.instance(); + final AppLinks appLinks; + final BuildContext context; + + StreamSubscription? _linkSub; + + bool resent = false; + bool isLoading = false; + AuthenticationResponses? lastResponse; + + ChangeEmailVerificationController({ + required this.appLinks, + required this.context, + }); + + void init() { + _linkSub = appLinks.uriLinkStream.listen(_handleUri); + } + + void dispose() { + _linkSub?.cancel(); + } + + /// Handles deeplink from email + Future _handleUri(Uri? uri) async { + if (uri != null && + uri.scheme == 'clean-stream' && + uri.host == 'change-email') { + await _authService.refreshSession(); + await _authService.getCurrentUser(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.go('/editProfile'); + } + }); + } + } + + /// Resends verification email + Future resendVerification() async { + if (resent) return; + + isLoading = true; + lastResponse = await _authService.resendVerification(); + isLoading = false; + + if (lastResponse == AuthenticationResponses.success) { + resent = true; + } + } +} \ No newline at end of file diff --git a/lib/features/change_email_verification/widgets/resend_verification.dart b/lib/features/change_email_verification/widgets/resend_verification.dart new file mode 100644 index 0000000..2d2b48a --- /dev/null +++ b/lib/features/change_email_verification/widgets/resend_verification.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../controller.dart'; +import 'verification_error.dart'; +import '../../../logic/enums/authentication_response_enum.dart'; + +class ResendVerificationWidget extends StatefulWidget { + final ChangeEmailVerificationController controller; + final VoidCallback onStateChange; + + const ResendVerificationWidget({ + super.key, + required this.controller, + required this.onStateChange, + }); + + @override + State createState() => + _ResendVerificationWidgetState(); +} + +class _ResendVerificationWidgetState extends State { + @override + Widget build(BuildContext context) { + if (widget.controller.isLoading) { + return const CircularProgressIndicator(); + } + + if (widget.controller.resent) { + return const Icon(Icons.check_circle, size: 40, color: Colors.green); + } + + return InkWell( + onTap: () async { + await widget.controller.resendVerification(); + widget.onStateChange(); + }, + child: widget.controller.lastResponse == null + ? const Text( + 'Resend Verification', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.blue, decoration: TextDecoration.underline), + ) + : widget.controller.lastResponse == AuthenticationResponses.failure + ? const VerificationError() + : const SizedBox.shrink(), + ); + } +} \ No newline at end of file diff --git a/lib/features/change_email_verification/widgets/verification_error.dart b/lib/features/change_email_verification/widgets/verification_error.dart new file mode 100644 index 0000000..454ad2a --- /dev/null +++ b/lib/features/change_email_verification/widgets/verification_error.dart @@ -0,0 +1,35 @@ +import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:flutter/material.dart'; + +class VerificationError extends StatelessWidget { + const VerificationError({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon(Icons.close, color: Colors.white, size: 40), + ), + ), + const SizedBox(height: 16), + Text( + 'Please resend verification again at another time.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontPrimary, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/supabase/functions/approveRefund/index.test.ts b/supabase/functions/approveRefund/logic.test.ts similarity index 100% rename from supabase/functions/approveRefund/index.test.ts rename to supabase/functions/approveRefund/logic.test.ts diff --git a/supabase/functions/checkPaymentResult/logic.test.ts b/supabase/functions/checkPaymentResult/logic.test.ts index 0e0f08a..1354f9a 100644 --- a/supabase/functions/checkPaymentResult/logic.test.ts +++ b/supabase/functions/checkPaymentResult/logic.test.ts @@ -15,7 +15,7 @@ Deno.test("throws if sessionId is missing", async () => { retrieveSession: async (id) => ({ payment_status: "paid" }), }; - await assertRejects( // await + assertRejects + await assertRejects( () => getPaymentStatusLogic({ sessionId: "" }, fakeDeps), Error, "Missing sessionId" diff --git a/supabase/functions/denyRefund/logic.test.ts b/supabase/functions/denyRefund/logic.test.ts index e66c93d..4b2e6c3 100644 --- a/supabase/functions/denyRefund/logic.test.ts +++ b/supabase/functions/denyRefund/logic.test.ts @@ -64,7 +64,6 @@ import { } function restoreFetch() { - // Deno's real fetch is on globalThis — reset after each test globalThis.fetch = fetch; } @@ -119,7 +118,6 @@ import { Deno.test("denyRefundInDb — resolves without error on success", async () => { const supabase = makeSupabaseMock(); - // should not throw await denyRefundInDb(supabase, "txn-abc"); }); @@ -178,7 +176,7 @@ import { }); Deno.test("getUserEmail — throws when user has no email", async () => { - const supabase = makeSupabaseMock({ user: { id: "user-123" } }); // no email field + const supabase = makeSupabaseMock({ user: { id: "user-123" } }); await assertRejects( () => getUserEmail(supabase, "user-123"), @@ -276,7 +274,6 @@ import { } finally { restoreFetch(); } - // reaching here without throwing is the assertion }); Deno.test("sendDenialEmail — throws with error text when response is not ok", async () => { diff --git a/supabase/functions/ping-device/logic.test.ts b/supabase/functions/ping-device/logic.test.ts index 2c5b2fe..345f2a7 100644 --- a/supabase/functions/ping-device/logic.test.ts +++ b/supabase/functions/ping-device/logic.test.ts @@ -1,61 +1,169 @@ -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`, - }, - }; - } +import { + assertEquals, + assertObjectMatch, +} from "https://deno.land/std@0.168.0/testing/asserts.ts"; +import { handleMachineRequest, Dependencies, MachineStatus } from "./logic.ts"; +function makeDeps(overrides: Partial = {}): Dependencies { return { - status: 503, - body: { - success: false, - deviceId, - error: "Device unreachable or timeout", - timestamp: new Date().toISOString(), - responseTime: `${responseDelay}ms`, - }, + getMachineStatus: (_id: string) => Promise.resolve("idle" as MachineStatus), + random: () => 0.5, + delay: (_ms: number) => Promise.resolve(), + ...overrides, }; +} + +Deno.test("handleMachineRequest — returns 400 when body is null", async () => { + const result = await handleMachineRequest(null, makeDeps()); + assertEquals(result.status, 400); + assertEquals(result.body, { error: "deviceId is required", receivedBody: null }); +}); + +Deno.test("handleMachineRequest — returns 400 when body is undefined", async () => { + const result = await handleMachineRequest(undefined, makeDeps()); + assertEquals(result.status, 400); + assertEquals(result.body, { error: "deviceId is required", receivedBody: undefined }); +}); + +Deno.test("handleMachineRequest — returns 400 when body has no deviceId", async () => { + const result = await handleMachineRequest({ foo: "bar" }, makeDeps()); + assertEquals(result.status, 400); + assertObjectMatch(result.body as Record, { error: "deviceId is required" }); +}); + +Deno.test("handleMachineRequest — returns 400 when deviceId is an empty string", async () => { + const result = await handleMachineRequest({ deviceId: "" }, makeDeps()); + assertEquals(result.status, 400); +}); + +Deno.test("handleMachineRequest — does not call delay or getMachineStatus when deviceId is missing", async () => { + let delayCalled = false; + let getStatusCalled = false; + + const deps = makeDeps({ + delay: (_ms: number) => { delayCalled = true; return Promise.resolve(); }, + getMachineStatus: (_id: string) => { getStatusCalled = true; return Promise.resolve("idle"); }, + }); + + await handleMachineRequest(null, deps); + + assertEquals(delayCalled, false); + assertEquals(getStatusCalled, false); +}); + +Deno.test("handleMachineRequest — returns 200 with correct shape on success", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps()); + assertEquals(result.status, 200); + assertObjectMatch(result.body as Record, { + success: true, + deviceId: "device-1", + message: "idle", + }); +}); + +Deno.test("handleMachineRequest — includes a valid ISO timestamp on success", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps()); + const { timestamp } = result.body as { timestamp: string }; + assertEquals(new Date(timestamp).toISOString(), timestamp); +}); + +Deno.test("handleMachineRequest — responseTime reflects computed delay", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps({ random: () => 0.5 })); + assertEquals((result.body as { responseTime: string }).responseTime, "125ms"); +}); + +Deno.test("handleMachineRequest — calls getMachineStatus with the correct deviceId", async () => { + let capturedId = ""; + const deps = makeDeps({ + getMachineStatus: (id: string) => { capturedId = id; return Promise.resolve("idle"); }, + }); + + await handleMachineRequest({ deviceId: "machine-xyz" }, deps); + assertEquals(capturedId, "machine-xyz"); +}); + +Deno.test("handleMachineRequest — message reflects getMachineStatus return value", async () => { + const deps = makeDeps({ + getMachineStatus: (_id: string) => Promise.resolve("in-use" as MachineStatus), + }); + const result = await handleMachineRequest({ deviceId: "device-1" }, deps); + assertEquals((result.body as { message: MachineStatus }).message, "in-use"); +}); + +Deno.test("handleMachineRequest — returns 503 when random is exactly 0.95", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps({ random: () => 0.95 })); + assertEquals(result.status, 503); +}); + +Deno.test("handleMachineRequest — returns 503 when random is 1", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps({ random: () => 1 })); + assertEquals(result.status, 503); +}); + +Deno.test("handleMachineRequest — returns correct error body on failure", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps({ random: () => 0.99 })); + assertObjectMatch(result.body as Record, { + success: false, + deviceId: "device-1", + error: "Device unreachable or timeout", + }); +}); + +Deno.test("handleMachineRequest — failure body includes timestamp and responseTime", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps({ random: () => 0.99 })); + const body = result.body as Record; + assertEquals(typeof body.timestamp, "string"); + assertEquals(typeof body.responseTime, "string"); +}); + +Deno.test("handleMachineRequest — delay fires before getMachineStatus", async () => { + const callOrder: string[] = []; + const deps = makeDeps({ + delay: (_ms: number) => { callOrder.push("delay"); return Promise.resolve(); }, + getMachineStatus: (_id: string) => { callOrder.push("getMachineStatus"); return Promise.resolve("idle"); }, + }); + + await handleMachineRequest({ deviceId: "device-1" }, deps); + assertEquals(callOrder, ["delay", "getMachineStatus"]); +}); + +Deno.test("handleMachineRequest — delay is floor(random() * 150) + 50 (min: random = 0)", async () => { + let capturedMs = -1; + const deps = makeDeps({ + random: () => 0, + delay: (ms: number) => { capturedMs = ms; return Promise.resolve(); }, + }); + + await handleMachineRequest({ deviceId: "device-1" }, deps); + assertEquals(capturedMs, 50); +}); + +Deno.test("handleMachineRequest — delay is within [50, 199] when random is near 1", async () => { + let capturedMs = -1; + const deps = makeDeps({ + random: () => 0.9999, + delay: (ms: number) => { capturedMs = ms; return Promise.resolve(); }, + }); + + await handleMachineRequest({ deviceId: "device-1" }, deps); + assertEquals(capturedMs >= 50, true); + assertEquals(capturedMs <= 199, true); +}); + + +Deno.test("handleMachineRequest — returns 200 when random is just below 0.95", async () => { + const result = await handleMachineRequest({ deviceId: "device-1" }, makeDeps({ random: () => 0.9499 })); + assertEquals(result.status, 200); +}); + +const statuses: MachineStatus[] = ["idle", "in-use", "offline", "error"]; + +for (const status of statuses) { + Deno.test(`handleMachineRequest — status "${status}" propagates to success body`, async () => { + const deps = makeDeps({ + getMachineStatus: (_id: string) => Promise.resolve(status), + }); + const result = await handleMachineRequest({ deviceId: "dev" }, deps); + assertEquals((result.body as { message: MachineStatus }).message, status); + }); } \ No newline at end of file diff --git a/supabase/functions/wakeDevice/logic.test.ts b/supabase/functions/wakeDevice/logic.test.ts index e0a42f0..b928479 100644 --- a/supabase/functions/wakeDevice/logic.test.ts +++ b/supabase/functions/wakeDevice/logic.test.ts @@ -38,8 +38,6 @@ import { }); 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], }); @@ -54,7 +52,6 @@ import { 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"); }); @@ -76,7 +73,7 @@ import { Deno.test("delay is awaited with correct ms", async () => { const { deps, getDelay } = createDeps({ - randomValues: [0.1, 0.0], // minimum delay + randomValues: [0.1, 0.0], }); await handleWakeDevice( @@ -84,6 +81,5 @@ import { deps ); - // floor(0 * 150) + 50 = 50 assertEquals(getDelay(), 50); }); \ No newline at end of file