1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 type CloudAccountSource,
5 createCloudAccountService,
6 planCloudAccountStep,
7} from "./cloudAccountService.ts";
8
9type Emit = (user: { readonly id: string } | null | undefined) => void;
10
11function makeSource() {
12 let listener: Parameters<CloudAccountSource["addListener"]>[0] | null = null;
13 const source: CloudAccountSource = {
14 addListener: (next) => {
15 listener = next;
16 return () => {
17 listener = null;
18 };
19 },
20 };
21 const emit: Emit = (user) => listener?.({ user });
22 return { source, emit, isSubscribed: () => listener !== null };
23}
24
25function makeService() {
26 const log: string[] = [];
27 const activated: Array<PromiseWithResolvers<void>> = [];
28 const cleanedUp: Array<PromiseWithResolvers<void>> = [];
29 const gates = new Map<
30 string,
31 { readonly started: PromiseWithResolvers<void>; readonly release: PromiseWithResolvers<void> }
32 >();
33 const service = createCloudAccountService<CloudAccountSource>({
34 deactivate: () => log.push("deactivate"),
35 detach: () => log.push("detach"),
36 onSignOutTransition: () => log.push("signOutTransition"),
37 cleanUp: async ({ accountId, wasActive }) => {
38 log.push(`cleanUp:${accountId}:${wasActive}`);
39 cleanedUp.shift()?.resolve();
40 },
41 prepare: async ({ accountId }) => {
42 log.push(`prepare:${accountId}`);
43 const gate = gates.get(accountId);
44 gate?.started.resolve();
45 await gate?.release.promise;
46 },
47 activate: ({ accountId, isAccountTransition }) => {
48 log.push(`activate:${accountId}:${isAccountTransition}`);
49 activated.shift()?.resolve();
50 },
51 });
52 const nextActivation = () => {
53 const deferred = Promise.withResolvers<void>();
54 activated.push(deferred);
55 return deferred.promise;
56 };
57 const nextCleanUp = () => {
58 const deferred = Promise.withResolvers<void>();
59 cleanedUp.push(deferred);
60 return deferred.promise;
61 };
62 const gate = (accountId: string) => {
63 const deferred = {
64 started: Promise.withResolvers<void>(),
65 release: Promise.withResolvers<void>(),
66 };
67 gates.set(accountId, deferred);
68 return deferred;
69 };
70 return { service, log, nextActivation, nextCleanUp, gate };
71}
72
73describe("planCloudAccountStep", () => {
74 it("treats the first observation as a non-transition", () => {
75 expect(planCloudAccountStep(undefined, "a")).toEqual({
76 _tag: "SignIn",
77 accountId: "a",
78 switchFromAccountId: null,
79 isAccountTransition: false,
80 });
81 expect(planCloudAccountStep(undefined, null)).toEqual({
82 _tag: "SignOut",
83 previousAccountId: null,
84 isAccountTransition: false,
85 });
86 });
87
88 it("ignores repeats and switches from the previous account", () => {
89 expect(planCloudAccountStep("a", "a")).toEqual({ _tag: "None" });
90 expect(planCloudAccountStep(null, null)).toEqual({ _tag: "None" });
91 expect(planCloudAccountStep("a", "b")).toEqual({
92 _tag: "SignIn",
93 accountId: "b",
94 switchFromAccountId: "a",
95 isAccountTransition: true,
96 });
97 });
98});
99
100describe("createCloudAccountService", () => {
101 it("activates once per account however often Clerk emits", async () => {
102 const { service, log, nextActivation } = makeService();
103 const { source, emit } = makeSource();
104 service.attach(source);
105
106 emit(undefined);
107 expect(service.getState()).toEqual({ _tag: "Unobserved" });
108 const activation = nextActivation();
109 emit({ id: "a" });
110 emit({ id: "a" });
111 expect(service.getState()).toEqual({ _tag: "Activating", accountId: "a" });
112 await activation;
113
114 expect(service.getState()).toEqual({ _tag: "Active", accountId: "a" });
115 expect(log).toEqual(["prepare:a", "activate:a:false"]);
116 });
117
118 it("drops credentials synchronously and cleans up before activating the next account", async () => {
119 const { service, log, nextActivation } = makeService();
120 const { source, emit } = makeSource();
121 service.attach(source);
122 const first = nextActivation();
123 emit({ id: "a" });
124 await first;
125 log.length = 0;
126
127 const second = nextActivation();
128 emit({ id: "b" });
129 expect(log).toEqual(["deactivate"]);
130 await second;
131
132 expect(log).toEqual(["deactivate", "cleanUp:a:true", "prepare:b", "activate:b:true"]);
133 });
134
135 it("cleans up on sign-out, including a signed-out first observation", async () => {
136 const { service, log, nextActivation, nextCleanUp } = makeService();
137 const { source, emit } = makeSource();
138 service.attach(source);
139
140 const initialCleanUp = nextCleanUp();
141 emit(null);
142 await initialCleanUp;
143 expect(log).toEqual(["deactivate", "cleanUp:null:false"]);
144
145 const activation = nextActivation();
146 emit({ id: "a" });
147 await activation;
148 log.length = 0;
149
150 const cleanUp = nextCleanUp();
151 emit(null);
152 await cleanUp;
153 expect(service.getState()).toEqual({ _tag: "SignedOut" });
154 expect(log).toEqual(["signOutTransition", "deactivate", "cleanUp:a:true"]);
155 });
156
157 it("never activates an account that signed out while it was activating", async () => {
158 const { service, log, nextCleanUp, gate } = makeService();
159 const { source, emit } = makeSource();
160 service.attach(source);
161 const prepared = gate("a");
162 emit({ id: "a" });
163 await prepared.started.promise;
164 const cleanUp = nextCleanUp();
165 emit(null);
166 prepared.release.resolve();
167 await cleanUp;
168
169 expect(service.getState()).toEqual({ _tag: "SignedOut" });
170 expect(log).toEqual(["prepare:a", "signOutTransition", "deactivate", "cleanUp:a:false"]);
171 });
172
173 it("retries a rejected activation on the next emission for the same account", async () => {
174 const log: string[] = [];
175 const failed = Promise.withResolvers<void>();
176 const activated = Promise.withResolvers<void>();
177 let attempts = 0;
178 const service = createCloudAccountService<CloudAccountSource>({
179 deactivate: () => log.push("deactivate"),
180 detach: () => log.push("detach"),
181 cleanUp: async ({ accountId, wasActive }) => {
182 log.push(`cleanUp:${accountId}:${wasActive}`);
183 },
184 prepare: async ({ accountId }) => {
185 attempts += 1;
186 log.push(`prepare:${accountId}`);
187 if (attempts === 1) {
188 // Queued before the rejection, so the service's catch runs before
189 // the test's `await failed.promise` continuation.
190 queueMicrotask(failed.resolve);
191 throw new Error("restore failed");
192 }
193 },
194 activate: ({ accountId, isAccountTransition }) => {
195 log.push(`activate:${accountId}:${isAccountTransition}`);
196 activated.resolve();
197 },
198 });
199 const { source, emit } = makeSource();
200 service.attach(source);
201 emit(null);
202 emit({ id: "a" });
203 await failed.promise;
204 expect(service.getState()).toMatchObject({ _tag: "Failed", accountId: "a" });
205
206 emit({ id: "a" });
207 await activated.promise;
208
209 expect(service.getState()).toEqual({ _tag: "Active", accountId: "a" });
210 expect(log).toEqual([
211 "deactivate",
212 "cleanUp:null:false",
213 "prepare:a",
214 "prepare:a",
215 "activate:a:true",
216 ]);
217 });
218
219 it("detaches from a replaced Clerk instance", () => {
220 const { service, log } = makeService();
221 const first = makeSource();
222 const second = makeSource();
223 service.attach(first.source);
224 service.attach(second.source);
225
226 expect(first.isSubscribed()).toBe(false);
227 expect(second.isSubscribed()).toBe(true);
228 expect(log).toEqual(["detach"]);
229 });
230
231 it("releases credentials and stops observing when detached", async () => {
232 const { service, log, nextActivation } = makeService();
233 const { source, emit, isSubscribed } = makeSource();
234 const detach = service.attach(source);
235 const activation = nextActivation();
236 emit({ id: "a" });
237 await activation;
238
239 detach();
240 expect(isSubscribed()).toBe(false);
241 expect(service.getState()).toEqual({ _tag: "Unobserved" });
242 expect(log).toEqual(["prepare:a", "activate:a:false", "detach"]);
243 });
244});