slack-objects 0.0.7__tar.gz → 0.0.9__tar.gz

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.
Files changed (72) hide show
  1. {slack_objects-0.0.7 → slack_objects-0.0.9}/.github/copilot-instructions.md +2 -1
  2. {slack_objects-0.0.7 → slack_objects-0.0.9}/PKG-INFO +1 -1
  3. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/_version.py +3 -3
  4. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/config.py +4 -0
  5. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/idp_groups.py +17 -8
  6. slack_objects-0.0.9/src/slack_objects/usergroups.py +105 -0
  7. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/users.py +27 -37
  8. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects.egg-info/PKG-INFO +1 -1
  9. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects.egg-info/SOURCES.txt +3 -0
  10. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/_smoke_harness.py +15 -0
  11. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/run_all_smoke.py +2 -0
  12. slack_objects-0.0.9/tests/Smoke/usergroups_smoke_test.py +58 -0
  13. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/users_smoke_test.py +2 -0
  14. slack_objects-0.0.9/tests/UnitTests/usergroups_test.py +192 -0
  15. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/users_unit_test.py +45 -2
  16. {slack_objects-0.0.7 → slack_objects-0.0.9}/.github/workflows/ci.yml +0 -0
  17. {slack_objects-0.0.7 → slack_objects-0.0.9}/.github/workflows/publish-pypi.yml +0 -0
  18. {slack_objects-0.0.7 → slack_objects-0.0.9}/.github/workflows/publish-testpypi.yml +0 -0
  19. {slack_objects-0.0.7 → slack_objects-0.0.9}/.gitignore +0 -0
  20. {slack_objects-0.0.7 → slack_objects-0.0.9}/.vs/VSWorkspaceState.json +0 -0
  21. {slack_objects-0.0.7 → slack_objects-0.0.9}/.vs/slnx.sqlite +0 -0
  22. {slack_objects-0.0.7 → slack_objects-0.0.9}/LICENSE +0 -0
  23. {slack_objects-0.0.7 → slack_objects-0.0.9}/README.md +0 -0
  24. {slack_objects-0.0.7 → slack_objects-0.0.9}/pyproject.toml +0 -0
  25. {slack_objects-0.0.7 → slack_objects-0.0.9}/pytest.ini +0 -0
  26. {slack_objects-0.0.7 → slack_objects-0.0.9}/setup.cfg +0 -0
  27. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/__init__.py +0 -0
  28. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/api_caller.py +0 -0
  29. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/base.py +0 -0
  30. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/client.py +0 -0
  31. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/conversations.py +0 -0
  32. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/files.py +0 -0
  33. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/messages.py +0 -0
  34. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/rate_limits.py +0 -0
  35. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/scim_base.py +0 -0
  36. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects/workspaces.py +0 -0
  37. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  38. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects.egg-info/requires.txt +0 -0
  39. {slack_objects-0.0.7 → slack_objects-0.0.9}/src/slack_objects.egg-info/top_level.txt +0 -0
  40. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Examples/examples_config.example.json +0 -0
  41. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Examples/users_search_by_email_example.py +0 -0
  42. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Examples/users_test_AzureKeyVault.py +0 -0
  43. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/conftest_live.py +0 -0
  44. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/run_all_scim_users_live_tests.py +0 -0
  45. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
  46. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_users_create_live.py +0 -0
  47. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
  48. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
  49. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
  50. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
  51. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
  52. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/api_caller_smoke_test.py +0 -0
  53. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/conversations_smoke_test.py +0 -0
  54. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/files_smoke_test.py +0 -0
  55. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/idp_groups_smoke_test.py +0 -0
  56. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/messages_smoke_test.py +0 -0
  57. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/security_smoke_test.py +0 -0
  58. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Smoke/workspaces_smoke_test.py +0 -0
  59. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/config_unit_test.py +0 -0
  60. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/conversations_unit_test.py +0 -0
  61. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/files_unit_test.py +0 -0
  62. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/idp_groups_unit_test.py +0 -0
  63. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/messages_unit_test.py +0 -0
  64. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/rate_limits_unit_test.py +0 -0
  65. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/UnitTests/workspaces_unit_test.py +0 -0
  66. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Users/conftest_live.py +0 -0
  67. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Users/run_all_users_live_tests.py +0 -0
  68. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Users/test_users_admin_live.py +0 -0
  69. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Users/test_users_classification_live.py +0 -0
  70. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Users/test_users_input_validation_live.py +0 -0
  71. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/Users/test_users_read_live.py +0 -0
  72. {slack_objects-0.0.7 → slack_objects-0.0.9}/tests/live_test_config.example.json +0 -0
@@ -1,4 +1,5 @@
1
1
  # Copilot Instructions
2
2
 
3
3
  ## Project Guidelines
4
- - Follow Unix philosophy: build small, composable functions where higher-level methods leverage lower-level primitives. Don't mix concerns (e.g., don't embed identifier resolution inside a SCIM CRUD method). Keep signatures consistent across sibling methods.
4
+ - Follow Unix philosophy: build small, composable functions where higher-level methods leverage lower-level primitives. Don't mix concerns (e.g., don't embed identifier resolution inside a SCIM CRUD method). Keep signatures consistent across sibling methods.
5
+ - Prefer consistency with the `Users.is_active`/`is_active_scim` pattern when structuring related methods (separate explicit SCIM method from higher-level orchestration).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.0.7
3
+ Version: 0.0.9
4
4
  Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
5
5
  Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
6
6
  Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.7'
32
- __version_tuple__ = version_tuple = (0, 0, 7)
31
+ __version__ = version = '0.0.9'
32
+ __version_tuple__ = version_tuple = (0, 0, 9)
33
33
 
34
- __commit_id__ = commit_id = 'g654a737d1'
34
+ __commit_id__ = commit_id = 'g82c79f1f4'
@@ -38,6 +38,9 @@ class SlackObjectsConfig:
38
38
  user_token: Optional[str] = field(default=None, repr=False)
39
39
  scim_token: Optional[str] = field(default=None, repr=False)
40
40
 
41
+ # Workspace ID — required for org-wide tokens when calling workspace-scoped Web APIs
42
+ team_id: Optional[str] = None
43
+
41
44
  default_rate_tier: RateTier = RateTier.TIER_2
42
45
 
43
46
  auth_idp_groups_read_access: dict[str, list[str]] = field(default_factory=dict)
@@ -59,6 +62,7 @@ class SlackObjectsConfig:
59
62
  f"bot_token={_mask(self.bot_token)}, "
60
63
  f"user_token={_mask(self.user_token)}, "
61
64
  f"scim_token={_mask(self.scim_token)}, "
65
+ f"team_id={self.team_id}, "
62
66
  f"default_rate_tier={self.default_rate_tier}, "
63
67
  f"auth_idp_groups_read_access={self.auth_idp_groups_read_access}, "
64
68
  f"auth_idp_groups_write_access={self.auth_idp_groups_write_access}, "
@@ -20,6 +20,7 @@ Design decisions
20
20
  - SCIM REST calls are centralized in ScimMixin._scim_request(); all public methods call endpoint wrappers.
21
21
  - Uses an injectable `requests.Session` (`scim_session`) so tests can pass a fake session.
22
22
  - Keeps legacy output shapes: lists of dicts for groups and members.
23
+ - This module is SCIM-only. For Slack-native usergroups, see ``usergroups.py``.
23
24
  """
24
25
 
25
26
  from dataclasses import dataclass, field
@@ -58,6 +59,15 @@ class IDP_groups(ScimMixin, SlackObjectBase):
58
59
  scim_session=self.scim_session,
59
60
  )
60
61
 
62
+ # ---------- identifier resolution ----------
63
+
64
+ def _resolve_group_id(self, group_id: Optional[str] = None) -> str:
65
+ """Resolve group_id from argument or bound instance value."""
66
+ gid = group_id or self.group_id
67
+ if not gid:
68
+ raise ValueError("group_id is required (passed or bound)")
69
+ return gid
70
+
61
71
  # ---------- endpoint wrappers (only these call _scim_request) ----------
62
72
 
63
73
  def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None) -> ScimResponse:
@@ -122,21 +132,20 @@ class IDP_groups(ScimMixin, SlackObjectBase):
122
132
 
123
133
  def get_members(self, group_id: Optional[str] = None) -> List[Dict[str, str]]:
124
134
  """
125
- Return the members of a group as a list of dicts `{'value': <user_id>, 'display': <name>}`.
135
+ Return group members via SCIM (GET Groups/{id}) as a list of dicts `{'value': <user_id>, 'display': <name>}`.
126
136
 
127
- If `group_id` omitted, uses bound `self.group_id`. Raises ValueError if none provided.
137
+ This module is SCIM-only. For Slack-native usergroups, see
138
+ ``Usergroups.get_members()`` in ``usergroups.py``.
128
139
  """
129
- gid = group_id or self.group_id
130
- if not gid:
131
- raise ValueError("get_members requires group_id (passed or bound)")
132
-
140
+ gid = self._resolve_group_id(group_id)
133
141
  scim_resp = self._scim_group_get(gid)
134
- # In the legacy scripts, group members are at `members` in the response body
135
142
  return scim_resp.data.get("members", [])
136
143
 
137
144
  def is_member(self, user_id: str, group_id: Optional[str] = None) -> bool:
138
145
  """
139
- Return True if `user_id` is a member of `group_id`.
146
+ Return True if ``user_id`` is a member of ``group_id`` (via SCIM).
147
+
148
+ Higher-level convenience that composes on ``get_members()``.
140
149
  Preserves legacy semantics (scans the members list).
141
150
  """
142
151
  members = self.get_members(group_id=group_id)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ slack_objects.usergroups
5
+ ========================
6
+
7
+ Usergroups helper for the `slack-objects` package.
8
+
9
+ Purpose
10
+ -------
11
+ Manage Slack-native user groups via the Web API (usergroups.*).
12
+ These are distinct from IdP (SCIM) groups managed in ``idp_groups.py``.
13
+
14
+ Design decisions
15
+ ----------------
16
+ - Only calls the Slack Web API via SlackApiCaller; no SCIM dependency.
17
+ - Keeps the same output shape as IDP_groups members
18
+ (``[{'value': <user_id>, 'display': ''}]``) so callers can swap sources.
19
+ """
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from .base import SlackObjectBase
25
+ from .config import RateTier
26
+
27
+
28
+ @dataclass
29
+ class Usergroups(SlackObjectBase):
30
+ """
31
+ Slack-native usergroups helper.
32
+
33
+ Factory usage:
34
+ slack = SlackObjectsClient(cfg)
35
+ ug = slack.usergroups() # unbound
36
+ bound = slack.usergroups("S0614TZR7") # bound to a usergroup_id
37
+ """
38
+ usergroup_id: Optional[str] = None
39
+
40
+ # ---------- factory ----------
41
+
42
+ def with_usergroup(self, usergroup_id: str) -> "Usergroups":
43
+ """Return a new instance bound to a particular usergroup_id, sharing cfg/client/logger/api."""
44
+ return Usergroups(
45
+ cfg=self.cfg,
46
+ client=self.client,
47
+ logger=self.logger,
48
+ api=self.api,
49
+ usergroup_id=usergroup_id,
50
+ )
51
+
52
+ # ---------- identifier resolution ----------
53
+
54
+ def _resolve_usergroup_id(self, usergroup_id: Optional[str] = None) -> str:
55
+ """Resolve usergroup_id from argument or bound instance value."""
56
+ ugid = usergroup_id or self.usergroup_id
57
+ if not ugid:
58
+ raise ValueError("usergroup_id is required (passed or bound)")
59
+ return ugid
60
+
61
+ # ---------- endpoint wrappers (only these call self.api.call) ----------
62
+
63
+ def _usergroups_list(self, **kwargs: Any) -> Dict[str, Any]:
64
+ """Wrapper for usergroups.list."""
65
+ if self.cfg.team_id:
66
+ kwargs.setdefault("team_id", self.cfg.team_id)
67
+ return self.api.call(self.client, "usergroups.list", rate_tier=RateTier.TIER_2, **kwargs)
68
+
69
+ def _usergroups_users_list(self, usergroup_id: str) -> Dict[str, Any]:
70
+ """Wrapper for usergroups.users.list."""
71
+ kwargs: Dict[str, Any] = {"usergroup": usergroup_id}
72
+ if self.cfg.team_id:
73
+ kwargs["team_id"] = self.cfg.team_id
74
+ return self.api.call(self.client, "usergroups.users.list", rate_tier=RateTier.TIER_2, **kwargs)
75
+
76
+ # ---------- public helpers ----------
77
+
78
+ def get_usergroups(self) -> List[Dict[str, Any]]:
79
+ """Return all usergroups visible to the bot token (usergroups.list)."""
80
+ resp = self._usergroups_list()
81
+ return resp.get("usergroups", [])
82
+
83
+ def get_members(self, usergroup_id: Optional[str] = None) -> List[Dict[str, str]]:
84
+ """
85
+ Return members of a Slack usergroup via the Web API (usergroups.users.list).
86
+
87
+ Returns the same shape as ``IDP_groups.get_members_scim()`` —
88
+ ``[{'value': <user_id>, 'display': ''}]`` — so callers can swap
89
+ sources without changing downstream code.
90
+
91
+ Requires the ``usergroups:read`` bot scope.
92
+ For org-wide tokens, ``cfg.team_id`` must be set.
93
+ """
94
+ ugid = self._resolve_usergroup_id(usergroup_id)
95
+ resp = self._usergroups_users_list(ugid)
96
+ users = resp.get("users", [])
97
+ return [{"value": uid, "display": ""} for uid in users]
98
+
99
+ def is_member(self, user_id: str, usergroup_id: Optional[str] = None) -> bool:
100
+ """Return True if ``user_id`` is a member of the usergroup."""
101
+ members = self.get_members(usergroup_id=usergroup_id)
102
+ for member in members:
103
+ if member.get("value") == user_id:
104
+ return True
105
+ return False
@@ -268,7 +268,11 @@ class Users(ScimMixin, SlackObjectBase):
268
268
  if not uid:
269
269
  raise ValueError("is_active_scim requires user_id (passed or bound)")
270
270
 
271
- scim_resp = self._scim_request(path=f"Users/{uid}", method="GET")
271
+ scim_resp = self._scim_request(
272
+ path=f"Users/{uid}",
273
+ method="GET",
274
+ raise_for_status=False,
275
+ )
272
276
  if not scim_resp.ok:
273
277
  return False
274
278
  return bool(scim_resp.data.get("active", False))
@@ -511,15 +515,27 @@ class Users(ScimMixin, SlackObjectBase):
511
515
 
512
516
  identifier = identifier.strip()
513
517
 
518
+ # Normalize: strip leading '@' so "@U1234" falls into the user-ID
519
+ # path and "@alice" falls into the username path — no special stage needed.
520
+ if identifier.startswith("@"):
521
+ identifier = identifier.removeprefix("@")
522
+
514
523
  # ── 1. Slack user ID ──────────────────────────────────────
515
524
  if self._looks_like_user_id(identifier):
516
- resp = self.get_user_info(identifier)
517
- if resp.get("ok"):
518
- return identifier
525
+ try:
526
+ resp = self.get_user_info(identifier)
527
+ if resp.get("ok"):
528
+ return identifier
529
+ except SlackApiError:
530
+ pass # expected for deactivated / not-found users
519
531
 
520
532
  # Slow path: SCIM (active + deactivated)
521
533
  self.logger.info("Web API miss for %s — falling back to SCIM lookup", identifier)
522
- scim_resp = self._scim_request(path=f"Users/{identifier}", method="GET")
534
+ scim_resp = self._scim_request(
535
+ path=f"Users/{identifier}",
536
+ method="GET",
537
+ raise_for_status=False,
538
+ )
523
539
  if scim_resp.ok and scim_resp.data.get("id"):
524
540
  return scim_resp.data["id"]
525
541
 
@@ -528,12 +544,9 @@ class Users(ScimMixin, SlackObjectBase):
528
544
  # ── 2. Email address ──────────────────────────────────────
529
545
  if EMAIL_RE.match(identifier):
530
546
  # Fast path: Web API (active users only)
531
- try:
532
- uid = self.get_user_id_from_email(identifier)
533
- if uid:
534
- return uid
535
- except SlackApiError:
536
- pass # expected for deactivated / not-found users
547
+ uid = self.get_user_id_from_email(identifier)
548
+ if uid:
549
+ return uid
537
550
 
538
551
  # Slow path: SCIM (active + deactivated)
539
552
  self.logger.info("Web API miss for %s — falling back to SCIM search", identifier)
@@ -543,32 +556,9 @@ class Users(ScimMixin, SlackObjectBase):
543
556
 
544
557
  raise LookupError(f"No user found for email: {identifier}")
545
558
 
546
- # ── 3. @username handle ───────────────────────────────────
547
- if identifier.startswith("@"):
548
- stripped = identifier.removeprefix("@")
549
-
550
- # The caller may have passed "@U1234" — recognise the ID
551
- # and verify via the cheaper Web API instead of SCIM.
552
- if self._looks_like_user_id(stripped):
553
- resp = self.get_user_info(stripped)
554
- if resp.get("ok"):
555
- return stripped
556
-
557
- # Slow path: SCIM (active + deactivated)
558
- self.logger.info("Web API miss for %s — falling back to SCIM lookup", stripped)
559
- scim_resp = self._scim_request(path=f"Users/{stripped}", method="GET")
560
- if scim_resp.ok and scim_resp.data.get("id"):
561
- return scim_resp.data["id"]
562
-
563
- raise LookupError(f"No user found for user ID: {stripped}")
564
-
565
- uid = self._first_scim_user_id(self.scim_search_user_by_username(stripped))
566
- if uid:
567
- return uid
568
-
569
- raise LookupError(f"No user found for username: @{stripped}")
570
-
571
- # ── 4. Bare string — try as username via SCIM ─────────────
559
+ # ── 3. Bare string / username — SCIM only ─────────────────
560
+ # No Web API endpoint exists for username lookup; SCIM is the
561
+ # only option. This also handles "@alice" (already stripped above).
572
562
  uid = self._first_scim_user_id(self.scim_search_user_by_username(identifier))
573
563
  if uid:
574
564
  return uid
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.0.7
3
+ Version: 0.0.9
4
4
  Summary: Opinionated, testable Python wrappers for Slack’s Web, Admin, and SCIM APIs, organized by object domain (users, conversations, messages, files, workspaces, and IdP groups). Designed for automation and administration workflows.
5
5
  Author-email: "Marcos E. Mercado" <marcos_elias@hotmail.com>
6
6
  Keywords: slack,objects,classes,slack objects,utilities,slack utilities,slack object types,slack types,types
@@ -21,6 +21,7 @@ src/slack_objects/idp_groups.py
21
21
  src/slack_objects/messages.py
22
22
  src/slack_objects/rate_limits.py
23
23
  src/slack_objects/scim_base.py
24
+ src/slack_objects/usergroups.py
24
25
  src/slack_objects/users.py
25
26
  src/slack_objects/workspaces.py
26
27
  src/slack_objects.egg-info/PKG-INFO
@@ -49,6 +50,7 @@ tests/Smoke/idp_groups_smoke_test.py
49
50
  tests/Smoke/messages_smoke_test.py
50
51
  tests/Smoke/run_all_smoke.py
51
52
  tests/Smoke/security_smoke_test.py
53
+ tests/Smoke/usergroups_smoke_test.py
52
54
  tests/Smoke/users_smoke_test.py
53
55
  tests/Smoke/workspaces_smoke_test.py
54
56
  tests/UnitTests/config_unit_test.py
@@ -57,6 +59,7 @@ tests/UnitTests/files_unit_test.py
57
59
  tests/UnitTests/idp_groups_unit_test.py
58
60
  tests/UnitTests/messages_unit_test.py
59
61
  tests/UnitTests/rate_limits_unit_test.py
62
+ tests/UnitTests/usergroups_test.py
60
63
  tests/UnitTests/users_unit_test.py
61
64
  tests/UnitTests/workspaces_unit_test.py
62
65
  tests/Users/conftest_live.py
@@ -190,6 +190,21 @@ class FakeWebClient:
190
190
  "offset": "",
191
191
  }
192
192
 
193
+ # ---------------------------
194
+ # Usergroups
195
+ # ---------------------------
196
+ if method == "usergroups.list":
197
+ return {
198
+ "ok": True,
199
+ "usergroups": [
200
+ {"id": "S0614TZR7", "name": "Team Admins", "handle": "admins"},
201
+ {"id": "S0615ABC8", "name": "Developers", "handle": "devs"},
202
+ ],
203
+ }
204
+
205
+ if method == "usergroups.users.list":
206
+ return {"ok": True, "users": ["U1", "U2"]}
207
+
193
208
  # Default: ok True
194
209
  return {"ok": True}
195
210
 
@@ -15,6 +15,7 @@ from tests.Smoke.idp_groups_smoke_test import main as idp_main
15
15
  from tests.Smoke.workspaces_smoke_test import main as workspaces_main
16
16
  from tests.Smoke.api_caller_smoke_test import main as api_caller_main
17
17
  from tests.Smoke.security_smoke_test import main as security_main
18
+ from tests.Smoke.usergroups_smoke_test import main as usergroups_main
18
19
 
19
20
 
20
21
  def main() -> None:
@@ -26,6 +27,7 @@ def main() -> None:
26
27
  workspaces_main()
27
28
  api_caller_main()
28
29
  security_main()
30
+ usergroups_main()
29
31
  print("\n✅ All smoke tests completed successfully.")
30
32
 
31
33
 
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from slack_objects.config import SlackObjectsConfig, RateTier
6
+ from slack_objects.usergroups import Usergroups
7
+
8
+ from tests.Smoke._smoke_harness import (
9
+ FakeWebClient,
10
+ FakeApiCaller,
11
+ CallSpec,
12
+ run_smoke,
13
+ )
14
+
15
+
16
+ def main() -> None:
17
+ logging.basicConfig(level=logging.INFO)
18
+ logger = logging.getLogger("smoke.usergroups")
19
+
20
+ cfg = SlackObjectsConfig(
21
+ bot_token="xoxb-fake",
22
+ default_rate_tier=RateTier.TIER_4,
23
+ )
24
+
25
+ client = FakeWebClient()
26
+ api = FakeApiCaller(cfg)
27
+
28
+ ug = Usergroups(
29
+ cfg=cfg,
30
+ client=client,
31
+ api=api,
32
+ logger=logger,
33
+ )
34
+
35
+ bound = ug.with_usergroup("S0614TZR7")
36
+
37
+ specs = [
38
+ # Factory helper
39
+ CallSpec("with_usergroup()", lambda: ug.with_usergroup("S0614TZR7")),
40
+
41
+ # Listing
42
+ CallSpec("get_usergroups()", lambda: ug.get_usergroups()),
43
+
44
+ # Members
45
+ CallSpec("get_members(by arg)", lambda: ug.get_members("S0614TZR7")),
46
+ CallSpec("get_members(bound)", lambda: bound.get_members()),
47
+
48
+ # Membership check
49
+ CallSpec("is_member(hit)", lambda: ug.is_member(user_id="U1", usergroup_id="S0614TZR7")),
50
+ CallSpec("is_member(miss)", lambda: ug.is_member(user_id="U_UNKNOWN", usergroup_id="S0614TZR7")),
51
+ CallSpec("is_member(bound)", lambda: bound.is_member(user_id="U2")),
52
+ ]
53
+
54
+ run_smoke("Usergroups smoke (all public methods)", specs)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -169,6 +169,8 @@ def main() -> None:
169
169
 
170
170
  # Identifier resolution
171
171
  CallSpec("resolve_user_id(user_id)", lambda: bound.resolve_user_id("U1")),
172
+ CallSpec("resolve_user_id(email)", lambda: bound.resolve_user_id("found@example.com")),
173
+ CallSpec("resolve_user_id(@username)", lambda: bound.resolve_user_id("@testuser")),
172
174
  ]
173
175
 
174
176
  run_smoke("Users smoke (all public methods)", specs)
@@ -0,0 +1,192 @@
1
+ """
2
+ Unit tests for slack_objects.usergroups
3
+ =======================================
4
+
5
+ All Slack API calls are mocked via ``api.call`` to test logic in isolation.
6
+
7
+ Run: python -m pytest tests/UnitTests/usergroups_test.py -v --tb=short
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from unittest.mock import MagicMock
14
+
15
+ import pytest
16
+
17
+ from slack_objects.config import SlackObjectsConfig, RateTier
18
+ from slack_objects.api_caller import SlackApiCaller
19
+ from slack_objects.usergroups import Usergroups
20
+
21
+
22
+ # ---------- helpers ----------
23
+
24
+ def _make_usergroups(
25
+ *,
26
+ usergroup_id: str | None = None,
27
+ team_id: str | None = None,
28
+ ) -> tuple[Usergroups, MagicMock]:
29
+ """Return a ``Usergroups`` instance with a mocked ``api.call``."""
30
+ cfg = SlackObjectsConfig(bot_token="xoxb-fake", team_id=team_id)
31
+ client = MagicMock()
32
+ api = MagicMock(spec=SlackApiCaller)
33
+ ug = Usergroups(
34
+ cfg=cfg,
35
+ client=client,
36
+ api=api,
37
+ logger=logging.getLogger("test"),
38
+ usergroup_id=usergroup_id,
39
+ )
40
+ return ug, api.call
41
+
42
+
43
+ # ---------- factory ----------
44
+
45
+ class TestWithUsergroup:
46
+ def test_returns_new_bound_instance(self):
47
+ ug, _ = _make_usergroups()
48
+ bound = ug.with_usergroup("S0614TZR7")
49
+ assert bound.usergroup_id == "S0614TZR7"
50
+ assert bound is not ug
51
+
52
+ def test_shares_cfg_and_api(self):
53
+ ug, _ = _make_usergroups()
54
+ bound = ug.with_usergroup("S0614TZR7")
55
+ assert bound.cfg is ug.cfg
56
+ assert bound.api is ug.api
57
+ assert bound.client is ug.client
58
+
59
+
60
+ # ---------- identifier resolution ----------
61
+
62
+ class TestResolveUsergroupId:
63
+ def test_uses_passed_value(self):
64
+ ug, _ = _make_usergroups(usergroup_id="SBOUND")
65
+ assert ug._resolve_usergroup_id("SPASSED") == "SPASSED"
66
+
67
+ def test_falls_back_to_bound(self):
68
+ ug, _ = _make_usergroups(usergroup_id="SBOUND")
69
+ assert ug._resolve_usergroup_id() == "SBOUND"
70
+
71
+ def test_raises_when_missing(self):
72
+ ug, _ = _make_usergroups()
73
+ with pytest.raises(ValueError, match="usergroup_id is required"):
74
+ ug._resolve_usergroup_id()
75
+
76
+
77
+ # ---------- endpoint wrappers ----------
78
+
79
+ class TestUsergroupsList:
80
+ def test_injects_team_id_when_configured(self):
81
+ ug, mock_call = _make_usergroups(team_id="T1234")
82
+ mock_call.return_value = {"ok": True, "usergroups": []}
83
+ ug._usergroups_list()
84
+ _, kwargs = mock_call.call_args
85
+ assert kwargs.get("team_id") == "T1234"
86
+
87
+ def test_omits_team_id_when_not_configured(self):
88
+ ug, mock_call = _make_usergroups(team_id=None)
89
+ mock_call.return_value = {"ok": True, "usergroups": []}
90
+ ug._usergroups_list()
91
+ _, kwargs = mock_call.call_args
92
+ assert "team_id" not in kwargs
93
+
94
+ def test_does_not_override_explicit_team_id(self):
95
+ ug, mock_call = _make_usergroups(team_id="TCFG")
96
+ mock_call.return_value = {"ok": True, "usergroups": []}
97
+ ug._usergroups_list(team_id="TEXPLICIT")
98
+ _, kwargs = mock_call.call_args
99
+ assert kwargs.get("team_id") == "TEXPLICIT"
100
+
101
+
102
+ class TestUsergroupsUsersList:
103
+ def test_injects_team_id_when_configured(self):
104
+ ug, mock_call = _make_usergroups(team_id="T1234")
105
+ mock_call.return_value = {"ok": True, "users": []}
106
+ ug._usergroups_users_list("S123")
107
+ _, kwargs = mock_call.call_args
108
+ assert kwargs.get("team_id") == "T1234"
109
+ assert kwargs.get("usergroup") == "S123"
110
+
111
+ def test_omits_team_id_when_not_configured(self):
112
+ ug, mock_call = _make_usergroups(team_id=None)
113
+ mock_call.return_value = {"ok": True, "users": []}
114
+ ug._usergroups_users_list("S123")
115
+ _, kwargs = mock_call.call_args
116
+ assert "team_id" not in kwargs
117
+
118
+
119
+ # ---------- get_usergroups ----------
120
+
121
+ class TestGetUsergroups:
122
+ def test_returns_usergroups_list(self):
123
+ ug, mock_call = _make_usergroups()
124
+ mock_call.return_value = {
125
+ "ok": True,
126
+ "usergroups": [
127
+ {"id": "S1", "name": "admins"},
128
+ {"id": "S2", "name": "devs"},
129
+ ],
130
+ }
131
+ result = ug.get_usergroups()
132
+ assert len(result) == 2
133
+ assert result[0]["id"] == "S1"
134
+
135
+ def test_returns_empty_list_on_missing_key(self):
136
+ ug, mock_call = _make_usergroups()
137
+ mock_call.return_value = {"ok": True}
138
+ assert ug.get_usergroups() == []
139
+
140
+
141
+ # ---------- get_members ----------
142
+
143
+ class TestGetMembers:
144
+ def test_returns_correct_shape(self):
145
+ ug, mock_call = _make_usergroups(usergroup_id="S123")
146
+ mock_call.return_value = {"ok": True, "users": ["U1", "U2", "U3"]}
147
+ members = ug.get_members()
148
+ assert members == [
149
+ {"value": "U1", "display": ""},
150
+ {"value": "U2", "display": ""},
151
+ {"value": "U3", "display": ""},
152
+ ]
153
+
154
+ def test_accepts_explicit_usergroup_id(self):
155
+ ug, mock_call = _make_usergroups()
156
+ mock_call.return_value = {"ok": True, "users": ["U9"]}
157
+ members = ug.get_members(usergroup_id="SEXPLICIT")
158
+ assert members == [{"value": "U9", "display": ""}]
159
+
160
+ def test_empty_users(self):
161
+ ug, mock_call = _make_usergroups(usergroup_id="S123")
162
+ mock_call.return_value = {"ok": True, "users": []}
163
+ assert ug.get_members() == []
164
+
165
+ def test_raises_without_usergroup_id(self):
166
+ ug, _ = _make_usergroups()
167
+ with pytest.raises(ValueError, match="usergroup_id is required"):
168
+ ug.get_members()
169
+
170
+
171
+ # ---------- is_member ----------
172
+
173
+ class TestIsMember:
174
+ def test_true_when_present(self):
175
+ ug, mock_call = _make_usergroups(usergroup_id="S123")
176
+ mock_call.return_value = {"ok": True, "users": ["U1", "U2"]}
177
+ assert ug.is_member("U2") is True
178
+
179
+ def test_false_when_absent(self):
180
+ ug, mock_call = _make_usergroups(usergroup_id="S123")
181
+ mock_call.return_value = {"ok": True, "users": ["U1", "U2"]}
182
+ assert ug.is_member("U999") is False
183
+
184
+ def test_accepts_explicit_usergroup_id(self):
185
+ ug, mock_call = _make_usergroups()
186
+ mock_call.return_value = {"ok": True, "users": ["UTARGET"]}
187
+ assert ug.is_member("UTARGET", usergroup_id="SEXPLICIT") is True
188
+
189
+ def test_raises_without_usergroup_id(self):
190
+ ug, _ = _make_usergroups()
191
+ with pytest.raises(ValueError, match="usergroup_id is required"):
192
+ ug.is_member("U1")
@@ -3,6 +3,7 @@ from typing import Any, Dict, Optional
3
3
  from unittest.mock import MagicMock
4
4
 
5
5
  import pytest
6
+ from slack_sdk.errors import SlackApiError
6
7
 
7
8
  from slack_objects.client import SlackObjectsClient
8
9
  from slack_objects.config import SlackObjectsConfig, RateTier, USER_ID_RE
@@ -182,6 +183,9 @@ class TestResolveUserId:
182
183
  )
183
184
  with pytest.raises(LookupError, match="No user found for user ID"):
184
185
  users.resolve_user_id("U00GHOST")
186
+ users._scim_request.assert_called_once_with(
187
+ path="Users/U00GHOST", method="GET", raise_for_status=False,
188
+ )
185
189
 
186
190
  def test_email_active_user(self):
187
191
  """Active user email resolves via Web API (fast path)."""
@@ -221,7 +225,7 @@ class TestResolveUserId:
221
225
  scim_resp = ScimResponse(ok=True, status_code=200, data={"Resources": []}, text="")
222
226
  users.scim_search_user_by_username = MagicMock(return_value=scim_resp)
223
227
 
224
- with pytest.raises(LookupError, match="No user found for username"):
228
+ with pytest.raises(LookupError, match="No user found for identifier"):
225
229
  users.resolve_user_id("@ghost")
226
230
 
227
231
  def test_bare_username(self):
@@ -258,6 +262,43 @@ class TestResolveUserId:
258
262
  )
259
263
  with pytest.raises(LookupError, match="No user found for user ID"):
260
264
  users.resolve_user_id("@U00GHOST")
265
+ users._scim_request.assert_called_once_with(
266
+ path="Users/U00GHOST", method="GET", raise_for_status=False,
267
+ )
268
+
269
+ def test_user_id_slack_api_error_falls_back_to_scim(self):
270
+ """SlackApiError from users.info falls through to SCIM lookup."""
271
+ users = _make_users()
272
+ users.get_user_info = MagicMock(
273
+ side_effect=SlackApiError("users_not_found", response=MagicMock())
274
+ )
275
+ scim_resp = ScimResponse(
276
+ ok=True, status_code=200,
277
+ data={"id": "USCIM", "userName": "scim-user", "active": True},
278
+ text="",
279
+ )
280
+ users._scim_request = MagicMock(return_value=scim_resp)
281
+
282
+ assert users.resolve_user_id("USCIM") == "USCIM"
283
+ users._scim_request.assert_called_once_with(
284
+ path="Users/USCIM", method="GET", raise_for_status=False,
285
+ )
286
+
287
+ def test_user_id_slack_api_error_and_scim_miss_raises(self):
288
+ """SlackApiError + SCIM miss still raises LookupError."""
289
+ users = _make_users()
290
+ users.get_user_info = MagicMock(
291
+ side_effect=SlackApiError("users_not_found", response=MagicMock())
292
+ )
293
+ users._scim_request = MagicMock(
294
+ return_value=ScimResponse(ok=False, status_code=404, data={}, text="")
295
+ )
296
+ with pytest.raises(LookupError, match="No user found for user ID"):
297
+ users.resolve_user_id("U00GHOST")
298
+ users._scim_request.assert_called_once_with(
299
+ path="Users/U00GHOST", method="GET", raise_for_status=False,
300
+ )
301
+
261
302
 
262
303
  # ═══════════════════════════════════════════════════════════════════════════
263
304
  # is_user_authorized
@@ -373,7 +414,9 @@ class TestIsActiveScim:
373
414
  return_value=ScimResponse(ok=True, status_code=200, data={"id": "U_OTHER", "active": True}, text=""),
374
415
  )
375
416
  assert bound.is_active_scim(user_id="U_OTHER") is True
376
- bound._scim_request.assert_called_once_with(path="Users/U_OTHER", method="GET")
417
+ bound._scim_request.assert_called_once_with(
418
+ path="Users/U_OTHER", method="GET", raise_for_status=False,
419
+ )
377
420
 
378
421
  def test_no_user_id_raises(self):
379
422
  """Unbound instance with no user_id raises ValueError."""
File without changes
File without changes
File without changes
File without changes
File without changes