| author | |
| committer | |
| log | 28e7c9ae17affc31e028a06cf33911fabe84d2cd |
| tree | 61917af2f664fcae74b627da8ad8993ef1a7f175 |
| parent | a9460bb772a8e25d39213ae62e73934152d44495 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 103 insertions(+), 6 deletions(-)
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+9-6| ... | ... | @@ -4,6 +4,7 @@ import { useState } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 6 | 6 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 7 | import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; | |
| 7 | 8 | |
| 8 | 9 | export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { |
| 9 | 10 | const { errors, fetchStatus, waitlist } = useWaitlist(); |
| ... | ... | @@ -21,12 +22,14 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 21 | 22 | |
| 22 | 23 | setRequestError(null); |
| 23 | 24 | try { |
| 24 | const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress }); | |
| 25 | if (error) { | |
| 26 | setRequestError("Could not join the waitlist. Check your email address and try again."); | |
| 27 | } | |
| 28 | } catch { | |
| 29 | setRequestError("Could not join the waitlist. Check your connection and try again."); | |
| 25 | await joinCloudWaitlist(waitlist, normalizedEmailAddress); | |
| 26 | } catch (error) { | |
| 27 | console.error(error); | |
| 28 | setRequestError( | |
| 29 | error instanceof CloudWaitlistJoinRejectedError | |
| 30 | ? "Could not join the waitlist. Check your email address and try again." | |
| 31 | : "Could not join the waitlist. Check your connection and try again.", | |
| 32 | ); | |
| 30 | 33 | } |
| 31 | 34 | }; |
| 32 | 35 |
apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts created+48| ... | ... | @@ -0,0 +1,48 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | CloudWaitlistJoinRejectedError, | |
| 5 | CloudWaitlistJoinRequestError, | |
| 6 | joinCloudWaitlist, | |
| 7 | } from "./cloudWaitlistJoin"; | |
| 8 | ||
| 9 | describe("joinCloudWaitlist", () => { | |
| 10 | it("submits the provided email address", async () => { | |
| 11 | const join = vi.fn().mockResolvedValue({ error: null }); | |
| 12 | ||
| 13 | await joinCloudWaitlist({ join }, "person@example.com"); | |
| 14 | ||
| 15 | expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); | |
| 16 | }); | |
| 17 | ||
| 18 | it("preserves Clerk rejection details without exposing the email address", async () => { | |
| 19 | const cause = Object.assign(new Error("The enrollment was rejected."), { | |
| 20 | code: "form_identifier_invalid", | |
| 21 | }); | |
| 22 | const join = vi.fn().mockResolvedValue({ error: cause }); | |
| 23 | ||
| 24 | const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( | |
| 25 | (error: unknown) => error, | |
| 26 | ); | |
| 27 | ||
| 28 | expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); | |
| 29 | expect(failure).toMatchObject({ | |
| 30 | code: "form_identifier_invalid", | |
| 31 | cause, | |
| 32 | }); | |
| 33 | expect(String(failure)).not.toContain("secret@example.com"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("distinguishes request failures from rejected enrollments", async () => { | |
| 37 | const cause = new Error("network unavailable"); | |
| 38 | const join = vi.fn().mockRejectedValue(cause); | |
| 39 | ||
| 40 | const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( | |
| 41 | (error: unknown) => error, | |
| 42 | ); | |
| 43 | ||
| 44 | expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); | |
| 45 | expect(failure).toMatchObject({ cause }); | |
| 46 | expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); | |
| 47 | }); | |
| 48 | }); |
apps/mobile/src/features/cloud/cloudWaitlistJoin.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | interface CloudWaitlistJoiner { | |
| 4 | readonly join: (input: { emailAddress: string }) => Promise<{ | |
| 5 | readonly error: { readonly code: string } | null; | |
| 6 | }>; | |
| 7 | } | |
| 8 | ||
| 9 | export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass<CloudWaitlistJoinRejectedError>()( | |
| 10 | "CloudWaitlistJoinRejectedError", | |
| 11 | { | |
| 12 | code: Schema.String, | |
| 13 | cause: Schema.Defect(), | |
| 14 | }, | |
| 15 | ) { | |
| 16 | override get message(): string { | |
| 17 | return `Cloud waitlist enrollment was rejected with code "${this.code}".`; | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass<CloudWaitlistJoinRequestError>()( | |
| 22 | "CloudWaitlistJoinRequestError", | |
| 23 | { | |
| 24 | cause: Schema.Defect(), | |
| 25 | }, | |
| 26 | ) { | |
| 27 | override get message(): string { | |
| 28 | return "Cloud waitlist enrollment request failed."; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export async function joinCloudWaitlist( | |
| 33 | waitlist: CloudWaitlistJoiner, | |
| 34 | emailAddress: string, | |
| 35 | ): Promise<void> { | |
| 36 | const result = await waitlist.join({ emailAddress }).catch((cause) => { | |
| 37 | throw new CloudWaitlistJoinRequestError({ cause }); | |
| 38 | }); | |
| 39 | ||
| 40 | if (result.error) { | |
| 41 | throw new CloudWaitlistJoinRejectedError({ | |
| 42 | code: result.error.code, | |
| 43 | cause: result.error, | |
| 44 | }); | |
| 45 | } | |
| 46 | } |