pytest-clerk-mock 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from typing import Any
5
+ from unittest.mock import MagicMock
6
+
7
+ import httpx
8
+ from clerk_backend_api.models import ClerkErrors
9
+ from clerk_backend_api.models.clerkerror import ClerkError
10
+ from clerk_backend_api.models.clerkerrors import ClerkErrorsData
11
+ from pydantic import BaseModel, Field
12
+
13
+ from pytest_clerk_mock.models.organization import MockOrganizationMembershipsResponse
14
+ from pytest_clerk_mock.models.user import MockEmailAddress, MockPhoneNumber, MockUser
15
+
16
+ EMAIL_EXISTS_ERROR_CODE = "form_identifier_exists"
17
+
18
+
19
+ class UserNotFoundError(Exception):
20
+ """Raised when a user is not found."""
21
+
22
+ def __init__(self, user_id: str) -> None:
23
+ self.user_id = user_id
24
+ super().__init__(f"User not found: {user_id}")
25
+
26
+
27
+ class MockListResponse(BaseModel):
28
+ """Response wrapper for list operations, matching Clerk SDK structure."""
29
+
30
+ data: list[MockUser] = Field(default_factory=list)
31
+
32
+
33
+ def _generate_id(prefix: str) -> str:
34
+ """Generate a Clerk-style ID with given prefix."""
35
+
36
+ return f"{prefix}_{secrets.token_hex(12)}"
37
+
38
+
39
+ def _create_email_exists_error(email: str) -> ClerkErrors:
40
+ """Create a ClerkErrors exception for duplicate email."""
41
+
42
+ mock_response = MagicMock(spec=httpx.Response)
43
+ mock_response.status_code = 422
44
+ mock_response.text = "That email address is taken."
45
+ mock_response.headers = httpx.Headers({})
46
+
47
+ return ClerkErrors(
48
+ data=ClerkErrorsData(
49
+ errors=[
50
+ ClerkError(
51
+ code=EMAIL_EXISTS_ERROR_CODE,
52
+ message="That email address is taken. Please try another.",
53
+ long_message="That email address is taken. Please try another.",
54
+ )
55
+ ]
56
+ ),
57
+ raw_response=mock_response,
58
+ )
59
+
60
+
61
+ class MockUsersClient:
62
+ """Mock implementation of Clerk's Users API."""
63
+
64
+ def __init__(self) -> None:
65
+ self._users: dict[str, MockUser] = {}
66
+ self._emails: dict[str, str] = {}
67
+ self._memberships: dict[str, MockOrganizationMembershipsResponse] = {}
68
+
69
+ def reset(self) -> None:
70
+ """Clear all stored users and email mappings."""
71
+
72
+ self._users.clear()
73
+ self._emails.clear()
74
+ self._memberships.clear()
75
+
76
+ def create(
77
+ self,
78
+ *,
79
+ email_address: list[str] | None = None,
80
+ phone_number: list[str] | None = None,
81
+ username: str | None = None,
82
+ password: str | None = None,
83
+ first_name: str | None = None,
84
+ last_name: str | None = None,
85
+ external_id: str | None = None,
86
+ public_metadata: dict[str, Any] | None = None,
87
+ private_metadata: dict[str, Any] | None = None,
88
+ unsafe_metadata: dict[str, Any] | None = None,
89
+ skip_password_checks: bool = False,
90
+ skip_password_requirement: bool = False,
91
+ totp_secret: str | None = None,
92
+ backup_codes: list[str] | None = None,
93
+ created_at: str | None = None,
94
+ ) -> MockUser:
95
+ """Create a new user."""
96
+
97
+ if email_address:
98
+ for email in email_address:
99
+ if email.lower() in self._emails:
100
+ raise _create_email_exists_error(email)
101
+
102
+ user_id = _generate_id("user")
103
+ email_objects: list[MockEmailAddress] = []
104
+ primary_email_id: str | None = None
105
+
106
+ if email_address:
107
+ for i, email in enumerate(email_address):
108
+ email_id = _generate_id("idn")
109
+ email_obj = MockEmailAddress.create(email=email, email_id=email_id)
110
+ email_objects.append(email_obj)
111
+ self._emails[email.lower()] = user_id
112
+
113
+ if i == 0:
114
+ primary_email_id = email_id
115
+
116
+ phone_objects: list[MockPhoneNumber] = []
117
+ primary_phone_id: str | None = None
118
+
119
+ if phone_number:
120
+ for i, phone in enumerate(phone_number):
121
+ phone_id = _generate_id("idn")
122
+ phone_obj = MockPhoneNumber.create(phone=phone, phone_id=phone_id)
123
+ phone_objects.append(phone_obj)
124
+
125
+ if i == 0:
126
+ primary_phone_id = phone_id
127
+
128
+ user = MockUser(
129
+ id=user_id,
130
+ external_id=external_id,
131
+ primary_email_address_id=primary_email_id,
132
+ primary_phone_number_id=primary_phone_id,
133
+ username=username,
134
+ first_name=first_name,
135
+ last_name=last_name,
136
+ email_addresses=email_objects,
137
+ phone_numbers=phone_objects,
138
+ password_enabled=password is not None,
139
+ public_metadata=public_metadata or {},
140
+ private_metadata=private_metadata or {},
141
+ unsafe_metadata=unsafe_metadata or {},
142
+ )
143
+
144
+ self._users[user_id] = user
145
+
146
+ return user
147
+
148
+ def get(self, user_id: str) -> MockUser:
149
+ """Get a user by ID."""
150
+
151
+ if user_id not in self._users:
152
+ raise UserNotFoundError(user_id)
153
+
154
+ return self._users[user_id]
155
+
156
+ def list(
157
+ self,
158
+ *,
159
+ email_address: list[str] | None = None,
160
+ phone_number: list[str] | None = None,
161
+ external_id: list[str] | None = None,
162
+ username: list[str] | None = None,
163
+ user_id: list[str] | None = None,
164
+ query: str | None = None,
165
+ last_active_at_since: int | None = None,
166
+ limit: int = 10,
167
+ offset: int = 0,
168
+ order_by: str = "-created_at",
169
+ ) -> list[MockUser]:
170
+ """List users with optional filters."""
171
+
172
+ users = list(self._users.values())
173
+
174
+ if email_address:
175
+ email_set = {e.lower() for e in email_address}
176
+ users = [
177
+ u
178
+ for u in users
179
+ if any(e.email_address.lower() in email_set for e in u.email_addresses)
180
+ ]
181
+
182
+ if phone_number:
183
+ phone_set = set(phone_number)
184
+ users = [
185
+ u
186
+ for u in users
187
+ if any(p.phone_number in phone_set for p in u.phone_numbers)
188
+ ]
189
+
190
+ if external_id:
191
+ ext_id_set = set(external_id)
192
+ users = [u for u in users if u.external_id in ext_id_set]
193
+
194
+ if username:
195
+ username_set = set(username)
196
+ users = [u for u in users if u.username in username_set]
197
+
198
+ if user_id:
199
+ user_id_set = set(user_id)
200
+ users = [u for u in users if u.id in user_id_set]
201
+
202
+ if query:
203
+ query_lower = query.lower()
204
+ users = [
205
+ u
206
+ for u in users
207
+ if (u.first_name and query_lower in u.first_name.lower())
208
+ or (u.last_name and query_lower in u.last_name.lower())
209
+ or (u.username and query_lower in u.username.lower())
210
+ or any(query_lower in e.email_address.lower() for e in u.email_addresses)
211
+ ]
212
+
213
+ reverse = order_by.startswith("-")
214
+ sort_key = order_by.lstrip("-+")
215
+
216
+ if sort_key == "created_at":
217
+ users.sort(key=lambda u: u.created_at, reverse=reverse)
218
+ elif sort_key == "updated_at":
219
+ users.sort(key=lambda u: u.updated_at, reverse=reverse)
220
+
221
+ return users[offset : offset + limit]
222
+
223
+ def update(
224
+ self,
225
+ user_id: str,
226
+ *,
227
+ external_id: str | None = None,
228
+ first_name: str | None = None,
229
+ last_name: str | None = None,
230
+ username: str | None = None,
231
+ password: str | None = None,
232
+ primary_email_address_id: str | None = None,
233
+ primary_phone_number_id: str | None = None,
234
+ public_metadata: dict[str, Any] | None = None,
235
+ private_metadata: dict[str, Any] | None = None,
236
+ unsafe_metadata: dict[str, Any] | None = None,
237
+ profile_image_id: str | None = None,
238
+ skip_password_checks: bool = False,
239
+ sign_out_of_other_sessions: bool = False,
240
+ totp_secret: str | None = None,
241
+ backup_codes: list[str] | None = None,
242
+ delete_self_enabled: bool | None = None,
243
+ create_organization_enabled: bool | None = None,
244
+ notify_primary_email_address_changed: bool = False,
245
+ ) -> MockUser:
246
+ """Update a user by ID."""
247
+
248
+ if user_id not in self._users:
249
+ raise UserNotFoundError(user_id)
250
+
251
+ user = self._users[user_id]
252
+ fields = {
253
+ "external_id": external_id,
254
+ "first_name": first_name,
255
+ "last_name": last_name,
256
+ "username": username,
257
+ "primary_email_address_id": primary_email_address_id,
258
+ "primary_phone_number_id": primary_phone_number_id,
259
+ "public_metadata": public_metadata,
260
+ "private_metadata": private_metadata,
261
+ "unsafe_metadata": unsafe_metadata,
262
+ "delete_self_enabled": delete_self_enabled,
263
+ "create_organization_enabled": create_organization_enabled,
264
+ }
265
+ update_data = {k: v for k, v in fields.items() if v is not None}
266
+
267
+ if password is not None:
268
+ update_data["password_enabled"] = True
269
+
270
+ updated_user = user.model_copy(update=update_data)
271
+ self._users[user_id] = updated_user
272
+
273
+ return updated_user
274
+
275
+ def delete(self, user_id: str) -> MockUser:
276
+ """Delete a user by ID."""
277
+
278
+ if user_id not in self._users:
279
+ raise UserNotFoundError(user_id)
280
+
281
+ user = self._users.pop(user_id)
282
+
283
+ for email in user.email_addresses:
284
+ self._emails.pop(email.email_address.lower(), None)
285
+
286
+ return user
287
+
288
+ def count(
289
+ self,
290
+ *,
291
+ email_address: list[str] | None = None,
292
+ phone_number: list[str] | None = None,
293
+ external_id: list[str] | None = None,
294
+ username: list[str] | None = None,
295
+ user_id: list[str] | None = None,
296
+ query: str | None = None,
297
+ ) -> int:
298
+ """Count users matching the filters."""
299
+
300
+ users = self.list(
301
+ email_address=email_address,
302
+ phone_number=phone_number,
303
+ external_id=external_id,
304
+ username=username,
305
+ user_id=user_id,
306
+ query=query,
307
+ limit=999999,
308
+ )
309
+
310
+ return len(users)
311
+
312
+ def set_organization_memberships(
313
+ self,
314
+ user_id: str,
315
+ memberships: MockOrganizationMembershipsResponse,
316
+ ) -> None:
317
+ """Configure organization memberships for a user."""
318
+
319
+ self._memberships[user_id] = memberships
320
+
321
+ def get_organization_memberships(
322
+ self,
323
+ user_id: str,
324
+ *,
325
+ limit: int | None = 10,
326
+ offset: int | None = 0,
327
+ ) -> MockOrganizationMembershipsResponse:
328
+ """Get organization memberships for a user (sync version)."""
329
+
330
+ return self._memberships.get(
331
+ user_id,
332
+ MockOrganizationMembershipsResponse(data=[], total_count=0),
333
+ )
334
+
335
+ async def get_organization_memberships_async(
336
+ self,
337
+ user_id: str,
338
+ *,
339
+ limit: int | None = 10,
340
+ offset: int | None = 0,
341
+ ) -> MockOrganizationMembershipsResponse:
342
+ """Get organization memberships for a user (async version)."""
343
+
344
+ return self.get_organization_memberships(user_id, limit=limit, offset=offset)
345
+
346
+ async def create_async(
347
+ self,
348
+ *,
349
+ email_address: list[str] | None = None,
350
+ phone_number: list[str] | None = None,
351
+ username: str | None = None,
352
+ password: str | None = None,
353
+ first_name: str | None = None,
354
+ last_name: str | None = None,
355
+ external_id: str | None = None,
356
+ public_metadata: dict[str, Any] | None = None,
357
+ private_metadata: dict[str, Any] | None = None,
358
+ unsafe_metadata: dict[str, Any] | None = None,
359
+ skip_password_checks: bool = False,
360
+ skip_password_requirement: bool = False,
361
+ totp_secret: str | None = None,
362
+ backup_codes: list[str] | None = None,
363
+ created_at: str | None = None,
364
+ ) -> MockUser:
365
+ """Async version of create."""
366
+
367
+ return self.create(
368
+ email_address=email_address,
369
+ phone_number=phone_number,
370
+ username=username,
371
+ password=password,
372
+ first_name=first_name,
373
+ last_name=last_name,
374
+ external_id=external_id,
375
+ public_metadata=public_metadata,
376
+ private_metadata=private_metadata,
377
+ unsafe_metadata=unsafe_metadata,
378
+ skip_password_checks=skip_password_checks,
379
+ skip_password_requirement=skip_password_requirement,
380
+ totp_secret=totp_secret,
381
+ backup_codes=backup_codes,
382
+ created_at=created_at,
383
+ )
384
+
385
+ async def get_async(self, user_id: str) -> MockUser:
386
+ """Async version of get."""
387
+
388
+ return self.get(user_id)
389
+
390
+ async def list_async(
391
+ self,
392
+ *,
393
+ email_address: list[str] | None = None,
394
+ phone_number: list[str] | None = None,
395
+ external_id: list[str] | None = None,
396
+ username: list[str] | None = None,
397
+ user_id: list[str] | None = None,
398
+ query: str | None = None,
399
+ last_active_at_since: int | None = None,
400
+ limit: int = 10,
401
+ offset: int = 0,
402
+ order_by: str = "-created_at",
403
+ ) -> MockListResponse:
404
+ """Async version of list.
405
+
406
+ Returns a MockListResponse with .data attribute to match Clerk SDK behavior.
407
+ """
408
+
409
+ users = self.list(
410
+ email_address=email_address,
411
+ phone_number=phone_number,
412
+ external_id=external_id,
413
+ username=username,
414
+ user_id=user_id,
415
+ query=query,
416
+ last_active_at_since=last_active_at_since,
417
+ limit=limit,
418
+ offset=offset,
419
+ order_by=order_by,
420
+ )
421
+
422
+ return MockListResponse(data=users)
423
+
424
+ async def update_async(
425
+ self,
426
+ user_id: str,
427
+ *,
428
+ external_id: str | None = None,
429
+ first_name: str | None = None,
430
+ last_name: str | None = None,
431
+ username: str | None = None,
432
+ password: str | None = None,
433
+ primary_email_address_id: str | None = None,
434
+ primary_phone_number_id: str | None = None,
435
+ public_metadata: dict[str, Any] | None = None,
436
+ private_metadata: dict[str, Any] | None = None,
437
+ unsafe_metadata: dict[str, Any] | None = None,
438
+ profile_image_id: str | None = None,
439
+ skip_password_checks: bool = False,
440
+ sign_out_of_other_sessions: bool = False,
441
+ totp_secret: str | None = None,
442
+ backup_codes: list[str] | None = None,
443
+ delete_self_enabled: bool | None = None,
444
+ create_organization_enabled: bool | None = None,
445
+ notify_primary_email_address_changed: bool = False,
446
+ ) -> MockUser:
447
+ """Async version of update."""
448
+
449
+ return self.update(
450
+ user_id,
451
+ external_id=external_id,
452
+ first_name=first_name,
453
+ last_name=last_name,
454
+ username=username,
455
+ password=password,
456
+ primary_email_address_id=primary_email_address_id,
457
+ primary_phone_number_id=primary_phone_number_id,
458
+ public_metadata=public_metadata,
459
+ private_metadata=private_metadata,
460
+ unsafe_metadata=unsafe_metadata,
461
+ profile_image_id=profile_image_id,
462
+ skip_password_checks=skip_password_checks,
463
+ sign_out_of_other_sessions=sign_out_of_other_sessions,
464
+ totp_secret=totp_secret,
465
+ backup_codes=backup_codes,
466
+ delete_self_enabled=delete_self_enabled,
467
+ create_organization_enabled=create_organization_enabled,
468
+ notify_primary_email_address_changed=notify_primary_email_address_changed,
469
+ )
470
+
471
+ async def delete_async(self, user_id: str) -> MockUser:
472
+ """Async version of delete."""
473
+
474
+ return self.delete(user_id)
475
+
476
+ async def count_async(
477
+ self,
478
+ *,
479
+ email_address: list[str] | None = None,
480
+ phone_number: list[str] | None = None,
481
+ external_id: list[str] | None = None,
482
+ username: list[str] | None = None,
483
+ user_id: list[str] | None = None,
484
+ query: str | None = None,
485
+ ) -> int:
486
+ """Async version of count."""
487
+
488
+ return self.count(
489
+ email_address=email_address,
490
+ phone_number=phone_number,
491
+ external_id=external_id,
492
+ username=username,
493
+ user_id=user_id,
494
+ query=query,
495
+ )