slack-objects 0.1.2__tar.gz → 0.2.0__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 (75) hide show
  1. {slack_objects-0.1.2 → slack_objects-0.2.0}/PKG-INFO +2 -1
  2. {slack_objects-0.1.2 → slack_objects-0.2.0}/README.md +2 -1
  3. slack_objects-0.2.0/pytest.ini +15 -0
  4. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/_version.py +3 -3
  5. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/client.py +1 -1
  6. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/users.py +27 -4
  7. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects.egg-info/PKG-INFO +2 -1
  8. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects.egg-info/SOURCES.txt +3 -0
  9. slack_objects-0.2.0/src/slack_objects.egg-info/scm_file_list.json +68 -0
  10. slack_objects-0.2.0/src/slack_objects.egg-info/scm_version.json +8 -0
  11. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/run_all_scim_users_live_tests.py +2 -1
  12. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/users_smoke_test.py +2 -2
  13. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/users_unit_test.py +172 -0
  14. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Users/run_all_users_live_tests.py +2 -1
  15. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Users/test_users_read_live.py +20 -9
  16. slack_objects-0.2.0/tests/conftest.py +31 -0
  17. slack_objects-0.1.2/pytest.ini +0 -3
  18. {slack_objects-0.1.2 → slack_objects-0.2.0}/.github/copilot-instructions.md +0 -0
  19. {slack_objects-0.1.2 → slack_objects-0.2.0}/.github/workflows/ci.yml +0 -0
  20. {slack_objects-0.1.2 → slack_objects-0.2.0}/.github/workflows/publish-pypi.yml +0 -0
  21. {slack_objects-0.1.2 → slack_objects-0.2.0}/.github/workflows/publish-testpypi.yml +0 -0
  22. {slack_objects-0.1.2 → slack_objects-0.2.0}/.gitignore +0 -0
  23. {slack_objects-0.1.2 → slack_objects-0.2.0}/LICENSE +0 -0
  24. {slack_objects-0.1.2 → slack_objects-0.2.0}/pyproject.toml +0 -0
  25. {slack_objects-0.1.2 → slack_objects-0.2.0}/setup.cfg +0 -0
  26. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/__init__.py +0 -0
  27. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/api_caller.py +0 -0
  28. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/base.py +0 -0
  29. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/config.py +0 -0
  30. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/conversations.py +0 -0
  31. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/files.py +0 -0
  32. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/idp_groups.py +0 -0
  33. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/messages.py +0 -0
  34. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/rate_limits.py +0 -0
  35. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/scim_base.py +0 -0
  36. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/usergroups.py +0 -0
  37. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects/workspaces.py +0 -0
  38. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
  39. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects.egg-info/requires.txt +0 -0
  40. {slack_objects-0.1.2 → slack_objects-0.2.0}/src/slack_objects.egg-info/top_level.txt +0 -0
  41. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Examples/examples_config.example.json +0 -0
  42. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Examples/users_search_by_email_example.py +0 -0
  43. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
  44. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/conftest_live.py +0 -0
  45. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
  46. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
  47. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
  48. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_input_validation_live.py +0 -0
  49. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
  50. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
  51. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
  52. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/SCIM/test_scim_users_update_email_live.py +0 -0
  53. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/_smoke_harness.py +0 -0
  54. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
  55. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/conversations_smoke_test.py +0 -0
  56. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/files_smoke_test.py +0 -0
  57. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
  58. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/messages_smoke_test.py +0 -0
  59. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/run_all_smoke.py +0 -0
  60. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/security_smoke_test.py +0 -0
  61. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
  62. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
  63. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/config_unit_test.py +0 -0
  64. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/conversations_unit_test.py +0 -0
  65. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/files_unit_test.py +0 -0
  66. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/idp_groups_unit_test.py +0 -0
  67. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/messages_unit_test.py +0 -0
  68. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
  69. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/usergroups_test.py +0 -0
  70. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
  71. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Users/conftest_live.py +0 -0
  72. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Users/test_users_admin_live.py +0 -0
  73. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Users/test_users_classification_live.py +0 -0
  74. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/Users/test_users_input_validation_live.py +0 -0
  75. {slack_objects-0.1.2 → slack_objects-0.2.0}/tests/live_test_config.example.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.1.2
3
+ Version: 0.2.0
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
@@ -198,3 +198,4 @@ explicit per-call tier → method-specific override → prefix rule → `default
198
198
  - `is_user_authorized` supports IdP-group-based authorization checks with configurable read/write access levels
199
199
  - SCIM user operations are available on `Users`: create, deactivate, reactivate, update attributes, update email, and convert to multi-channel guest
200
200
  - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
201
+ - `Users.get_channels(include_channels_user_left=True)` and `Conversations.get_members(include_members_who_left=True)` opt into historical data; both default to `False` so the common case makes fewer paginated calls
@@ -176,4 +176,5 @@ explicit per-call tier → method-specific override → prefix rule → `default
176
176
  - `resolve_user_id` accepts flexible identifiers (user ID, email, or @username) and verifies existence via Web API + SCIM fallback
177
177
  - `is_user_authorized` supports IdP-group-based authorization checks with configurable read/write access levels
178
178
  - SCIM user operations are available on `Users`: create, deactivate, reactivate, update attributes, update email, and convert to multi-channel guest
179
- - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
179
+ - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
180
+ - `Users.get_channels(include_channels_user_left=True)` and `Conversations.get_members(include_members_who_left=True)` opt into historical data; both default to `False` so the common case makes fewer paginated calls
@@ -0,0 +1,15 @@
1
+ [pytest]
2
+ pythonpath =
3
+ src
4
+
5
+ # Test tiers
6
+ # ----------
7
+ # live: hits real Azure Key Vault + real Slack APIs. Applied automatically to
8
+ # every '*_live.py' file by tests/conftest.py. Some live tests (tests/SCIM)
9
+ # mutate real users, so they are NOT run by default.
10
+ markers =
11
+ live: requires real credentials and makes live Slack API calls (deselected by default)
12
+
13
+ # Deselect the live tier by default so a bare 'pytest' is safe to run.
14
+ # Override with '-m live' (only live tests) or '-m "live or not live"' (everything).
15
+ addopts = -m "not live"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.2'
22
- __version_tuple__ = version_tuple = (0, 1, 2)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'ge0d1b7810'
24
+ __commit_id__ = commit_id = 'g7fa644b5a'
@@ -18,7 +18,7 @@ class SlackObjectsClient:
18
18
  Owns config, Slack client, and rate-limited API caller.
19
19
  """
20
20
 
21
- def __init__(self, cfg: SlackObjectsConfig, logger: logging.Logger | None = None):
21
+ def __init__(self, cfg: SlackObjectsConfig, logger: Optional[logging.Logger] = None):
22
22
  self.cfg = cfg
23
23
  self.logger = logger or logging.getLogger("slack-objects")
24
24
 
@@ -404,19 +404,39 @@ class Users(ScimMixin, SlackObjectBase):
404
404
 
405
405
  # ---------- discovery helper ----------
406
406
 
407
- def get_channels(self, user_id: str, active_only: bool = True) -> List[Dict[str, Any]]:
407
+ def get_channels(self, user_id: str, include_channels_user_left: bool = False) -> List[Dict[str, Any]]:
408
408
  """
409
409
  discovery.user.conversations, paginated by offset.
410
410
 
411
+ Args:
412
+ user_id: The Slack user whose conversations should be listed.
413
+ include_channels_user_left: When True, also return channels the user
414
+ has left (those with a non-zero ``date_left``). Mirrors
415
+ ``Conversations.get_members(include_members_who_left=...)``.
416
+
417
+ Note:
418
+ Slack only returns channels the user has left when ``include_historical``
419
+ is sent, so this flag controls the *request* and not just local filtering.
420
+ Leaving it False keeps the common case cheaper: Slack returns fewer
421
+ channels, which also means fewer paginated calls.
422
+
411
423
  Returns:
412
424
  - If errors occur: a list of error dicts (legacy behavior preserved)
413
- - Else: list of channels (active_only controls whether it filters to channels with date_left == 0)
425
+ - Else: the user's channels, including left channels only when
426
+ ``include_channels_user_left`` is True
414
427
  """
415
428
  current_channels: List[Dict[str, Any]] = []
416
429
  all_channels: List[Dict[str, Any]] = []
417
430
  errors: List[Dict[str, Any]] = []
418
431
 
419
- payload: Dict[str, Any] = {"user": user_id, "limit": 1000}
432
+ payload: Dict[str, Any] = {
433
+ "user": user_id,
434
+ "limit": 1000,
435
+ # Sent explicitly in both states so the behavior does not depend on
436
+ # Slack's server-side default. The Slack SDK serializes booleans to
437
+ # "0"/"1", so False is transmitted correctly.
438
+ "include_historical": include_channels_user_left,
439
+ }
420
440
 
421
441
  while True:
422
442
  resp = self._discovery_user_conversations(payload)
@@ -428,6 +448,7 @@ class Users(ScimMixin, SlackObjectBase):
428
448
  channels = resp.get("channels") or []
429
449
  for ch in channels:
430
450
  all_channels.append(ch)
451
+ # date_left is 0 for channels the user is still a member of.
431
452
  if ch.get("date_left", 0) == 0:
432
453
  current_channels.append(ch)
433
454
 
@@ -440,7 +461,9 @@ class Users(ScimMixin, SlackObjectBase):
440
461
  if errors:
441
462
  return errors # preserve legacy behavior
442
463
 
443
- return current_channels if active_only else all_channels
464
+ # The filter is a safety net: when include_historical is False Slack
465
+ # should not return left channels in the first place.
466
+ return all_channels if include_channels_user_left else current_channels
444
467
 
445
468
  # ============================================================
446
469
  # SCIM public methods (use inherited ScimMixin._scim_request)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-objects
3
- Version: 0.1.2
3
+ Version: 0.2.0
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
@@ -198,3 +198,4 @@ explicit per-call tier → method-specific override → prefix rule → `default
198
198
  - `is_user_authorized` supports IdP-group-based authorization checks with configurable read/write access levels
199
199
  - SCIM user operations are available on `Users`: create, deactivate, reactivate, update attributes, update email, and convert to multi-channel guest
200
200
  - Discovery API is used for `Users.get_channels` and `Conversations.get_members` (requires appropriate token scopes)
201
+ - `Users.get_channels(include_channels_user_left=True)` and `Conversations.get_members(include_members_who_left=True)` opt into historical data; both default to `False` so the common case makes fewer paginated calls
@@ -26,7 +26,10 @@ src/slack_objects.egg-info/PKG-INFO
26
26
  src/slack_objects.egg-info/SOURCES.txt
27
27
  src/slack_objects.egg-info/dependency_links.txt
28
28
  src/slack_objects.egg-info/requires.txt
29
+ src/slack_objects.egg-info/scm_file_list.json
30
+ src/slack_objects.egg-info/scm_version.json
29
31
  src/slack_objects.egg-info/top_level.txt
32
+ tests/conftest.py
30
33
  tests/live_test_config.example.json
31
34
  tests/Examples/examples_config.example.json
32
35
  tests/Examples/users_search_by_email_example.py
@@ -0,0 +1,68 @@
1
+ {
2
+ "files": [
3
+ "pyproject.toml",
4
+ "LICENSE",
5
+ "README.md",
6
+ "pytest.ini",
7
+ ".gitignore",
8
+ ".github/copilot-instructions.md",
9
+ ".github/workflows/publish-testpypi.yml",
10
+ ".github/workflows/publish-pypi.yml",
11
+ ".github/workflows/ci.yml",
12
+ "tests/live_test_config.example.json",
13
+ "tests/conftest.py",
14
+ "tests/UnitTests/usergroups_test.py",
15
+ "tests/UnitTests/rate_limits_unit_test.py",
16
+ "tests/UnitTests/files_unit_test.py",
17
+ "tests/UnitTests/idp_groups_unit_test.py",
18
+ "tests/UnitTests/messages_unit_test.py",
19
+ "tests/UnitTests/workspaces_unit_test.py",
20
+ "tests/UnitTests/users_unit_test.py",
21
+ "tests/UnitTests/conversations_unit_test.py",
22
+ "tests/UnitTests/config_unit_test.py",
23
+ "tests/SCIM/test_scim_users_make_guest_live.py",
24
+ "tests/SCIM/test_scim_users_update_email_live.py",
25
+ "tests/SCIM/test_scim_users_reactivate_live.py",
26
+ "tests/SCIM/conftest_live.py",
27
+ "tests/SCIM/test_scim_users_update_attribute_live.py",
28
+ "tests/SCIM/test_scim_users_input_validation_live.py",
29
+ "tests/SCIM/run_all_scim_users_live_tests.py",
30
+ "tests/SCIM/test_scim_users_create_live.py",
31
+ "tests/SCIM/test_scim_users_deactivate_live.py",
32
+ "tests/SCIM/test_scim_idp_groups_live.py",
33
+ "tests/Smoke/idp_groups_smoke_test.py",
34
+ "tests/Smoke/security_smoke_test.py",
35
+ "tests/Smoke/api_caller_smoke_test.py",
36
+ "tests/Smoke/run_all_smoke.py",
37
+ "tests/Smoke/_smoke_harness.py",
38
+ "tests/Smoke/users_smoke_test.py",
39
+ "tests/Smoke/files_smoke_test.py",
40
+ "tests/Smoke/workspaces_smoke_test.py",
41
+ "tests/Smoke/conversations_smoke_test.py",
42
+ "tests/Smoke/messages_smoke_test.py",
43
+ "tests/Smoke/usergroups_smoke_test.py",
44
+ "tests/Users/test_users_read_live.py",
45
+ "tests/Users/conftest_live.py",
46
+ "tests/Users/test_users_input_validation_live.py",
47
+ "tests/Users/run_all_users_live_tests.py",
48
+ "tests/Users/test_users_classification_live.py",
49
+ "tests/Users/test_users_admin_live.py",
50
+ "tests/Examples/users_search_by_email_example.py",
51
+ "tests/Examples/users_test_AzureKeyVault.py",
52
+ "tests/Examples/examples_config.example.json",
53
+ "src/slack_objects/workspaces.py",
54
+ "src/slack_objects/usergroups.py",
55
+ "src/slack_objects/conversations.py",
56
+ "src/slack_objects/messages.py",
57
+ "src/slack_objects/users.py",
58
+ "src/slack_objects/config.py",
59
+ "src/slack_objects/__init__.py",
60
+ "src/slack_objects/client.py",
61
+ "src/slack_objects/files.py",
62
+ "src/slack_objects/api_caller.py",
63
+ "src/slack_objects/scim_base.py",
64
+ "src/slack_objects/rate_limits.py",
65
+ "src/slack_objects/base.py",
66
+ "src/slack_objects/idp_groups.py"
67
+ ]
68
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.0",
3
+ "distance": 0,
4
+ "node": "g7fa644b5a865631efd38f980ea488ba704bda19a",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-31"
8
+ }
@@ -49,7 +49,8 @@ def main() -> int:
49
49
  print(f"{'═' * 70}\n")
50
50
 
51
51
  rc = subprocess.call(
52
- [sys.executable, "-m", "pytest", str(filepath), *extra_args],
52
+ # '-m live' re-enables the live tier, which pytest.ini deselects by default.
53
+ [sys.executable, "-m", "pytest", "-m", "live", str(filepath), *extra_args],
53
54
  cwd=str(_HERE.parent.parent), # workspace root
54
55
  )
55
56
  results[filename] = rc
@@ -136,8 +136,8 @@ def main() -> None:
136
136
  CallSpec("remove_from_conversation()", lambda: bound.remove_from_conversation("U1", "C1")),
137
137
 
138
138
  # Discovery helper
139
- CallSpec("get_channels(active_only)", lambda: bound.get_channels("U1", active_only=True)),
140
- CallSpec("get_channels(all)", lambda: bound.get_channels("U1", active_only=False)),
139
+ CallSpec("get_channels(current only)", lambda: bound.get_channels("U1")),
140
+ CallSpec("get_channels(including left)", lambda: bound.get_channels("U1", include_channels_user_left=True)),
141
141
 
142
142
  # Authorization helper
143
143
  CallSpec(
@@ -663,3 +663,175 @@ class TestScimUpdateUserAttribute:
663
663
  users = _make_users()
664
664
  with pytest.raises(ValueError):
665
665
  users.scim_update_user_attribute(user_id="", attribute="active", new_value=False)
666
+
667
+
668
+ # ═══════════════════════════════════════════════════════════════════════════
669
+ # get_channels (discovery.user.conversations)
670
+ # ═══════════════════════════════════════════════════════════════════════════
671
+
672
+ class FakeDiscoveryWebClient:
673
+ """Fake discovery.user.conversations that honors include_historical.
674
+
675
+ Mirrors real Slack behavior confirmed against the Discovery API: channels the
676
+ user has left are returned ONLY when include_historical is truthy. The Slack
677
+ SDK serializes booleans to "0"/"1", so this accepts both forms.
678
+
679
+ Also records every payload it receives so tests can assert on the request.
680
+ """
681
+
682
+ CURRENT = [
683
+ {"id": "C001", "date_joined": 1665776256, "date_left": 0},
684
+ {"id": "C002", "date_joined": 1665776257, "date_left": 0},
685
+ ]
686
+ LEFT = [
687
+ {"id": "C900", "date_joined": 1665776258, "date_left": 1700000000},
688
+ ]
689
+
690
+ def __init__(self, paginate: bool = False):
691
+ self.payloads: list = []
692
+ self.paginate = paginate
693
+
694
+ @staticmethod
695
+ def _is_truthy(value) -> bool:
696
+ # Accept True, "1", and 1; the SDK converts booleans to "0"/"1".
697
+ return value in (True, 1, "1")
698
+
699
+ def api_call(self, method: str, json=None, params=None):
700
+ payload = json if json is not None else (params or {})
701
+
702
+ if method != "discovery.user.conversations":
703
+ return {"ok": True}
704
+
705
+ self.payloads.append(dict(payload))
706
+
707
+ include_historical = self._is_truthy(payload.get("include_historical"))
708
+ channels = list(self.CURRENT)
709
+ if include_historical:
710
+ channels += self.LEFT
711
+
712
+ # Emit two pages on the first call when pagination is being exercised.
713
+ if self.paginate and "offset" not in payload:
714
+ return {"ok": True, "channels": channels, "offset": "page2"}
715
+
716
+ return {"ok": True, "channels": channels}
717
+
718
+
719
+ class FakeDiscoveryErrorWebClient:
720
+ """Fake that always fails, to exercise the legacy error-list behavior."""
721
+
722
+ def api_call(self, method: str, json=None, params=None):
723
+ if method == "discovery.user.conversations":
724
+ return {"ok": False, "error": "user_not_found"}
725
+ return {"ok": True}
726
+
727
+
728
+ def _make_users_with_client(web_client):
729
+ """Helper: build a Users instance wired to a specific fake web client."""
730
+ cfg = SlackObjectsConfig(
731
+ bot_token="xoxb-fake",
732
+ user_token="xoxp-fake",
733
+ scim_token="xoxp-fake",
734
+ default_rate_tier=RateTier.TIER_3,
735
+ )
736
+ slack = SlackObjectsClient(cfg, logger=logging.getLogger("test"))
737
+ slack.web_client = web_client
738
+ slack.api = FakeApiCaller(cfg)
739
+ slack._users = None
740
+ return slack.users()
741
+
742
+
743
+ class TestGetChannels:
744
+ """get_channels — include_channels_user_left controls the request, not just filtering."""
745
+
746
+ def test_default_does_not_request_historical(self):
747
+ """Default call must send include_historical=False.
748
+
749
+ This is the efficiency guarantee: the common case should not make Slack
750
+ return channels the user left only for them to be filtered out locally.
751
+ """
752
+ fake = FakeDiscoveryWebClient()
753
+ users = _make_users_with_client(fake)
754
+
755
+ users.get_channels("U1")
756
+
757
+ assert fake.payloads[0]["include_historical"] is False
758
+
759
+ def test_include_left_requests_historical(self):
760
+ """include_channels_user_left=True must send include_historical=True."""
761
+ fake = FakeDiscoveryWebClient()
762
+ users = _make_users_with_client(fake)
763
+
764
+ users.get_channels("U1", include_channels_user_left=True)
765
+
766
+ assert fake.payloads[0]["include_historical"] is True
767
+
768
+ def test_default_returns_only_current_channels(self):
769
+ """Default call returns just the channels the user is still in."""
770
+ users = _make_users_with_client(FakeDiscoveryWebClient())
771
+
772
+ result = users.get_channels("U1")
773
+
774
+ assert [c["id"] for c in result] == ["C001", "C002"]
775
+
776
+ def test_include_left_returns_current_and_left(self):
777
+ """include_channels_user_left=True returns left channels too."""
778
+ users = _make_users_with_client(FakeDiscoveryWebClient())
779
+
780
+ result = users.get_channels("U1", include_channels_user_left=True)
781
+
782
+ assert [c["id"] for c in result] == ["C001", "C002", "C900"]
783
+
784
+ def test_include_left_returns_more_than_default(self):
785
+ """The two modes must not return identical data (the original bug)."""
786
+ users = _make_users_with_client(FakeDiscoveryWebClient())
787
+
788
+ current = users.get_channels("U1")
789
+ including_left = users.get_channels("U1", include_channels_user_left=True)
790
+
791
+ assert len(including_left) > len(current)
792
+
793
+ def test_request_includes_user_and_limit(self):
794
+ """Payload keeps the documented user/limit parameters."""
795
+ fake = FakeDiscoveryWebClient()
796
+ users = _make_users_with_client(fake)
797
+
798
+ users.get_channels("U1")
799
+
800
+ assert fake.payloads[0]["user"] == "U1"
801
+ assert fake.payloads[0]["limit"] == 1000
802
+
803
+ def test_pagination_follows_offset(self):
804
+ """An offset in the response triggers another call and results accumulate."""
805
+ fake = FakeDiscoveryWebClient(paginate=True)
806
+ users = _make_users_with_client(fake)
807
+
808
+ result = users.get_channels("U1")
809
+
810
+ assert len(fake.payloads) == 2
811
+ assert fake.payloads[1]["offset"] == "page2"
812
+ assert len(result) == 4 # two current channels per page
813
+
814
+ def test_pagination_preserves_historical_flag(self):
815
+ """include_historical must persist across paginated requests."""
816
+ fake = FakeDiscoveryWebClient(paginate=True)
817
+ users = _make_users_with_client(fake)
818
+
819
+ users.get_channels("U1", include_channels_user_left=True)
820
+
821
+ assert all(p["include_historical"] is True for p in fake.payloads)
822
+
823
+ def test_api_error_returns_error_list(self):
824
+ """Legacy behavior: API failures come back as a list of error dicts."""
825
+ users = _make_users_with_client(FakeDiscoveryErrorWebClient())
826
+
827
+ result = users.get_channels("U1")
828
+
829
+ assert isinstance(result, list)
830
+ assert result[0]["message"] == "user_not_found"
831
+
832
+ def test_active_only_no_longer_accepted(self):
833
+ """The old active_only parameter was replaced and must not silently work."""
834
+ users = _make_users_with_client(FakeDiscoveryWebClient())
835
+
836
+ with pytest.raises(TypeError):
837
+ users.get_channels("U1", active_only=False)
@@ -45,7 +45,8 @@ def main() -> int:
45
45
  print(f"{'═' * 70}\n")
46
46
 
47
47
  rc = subprocess.call(
48
- [sys.executable, "-m", "pytest", str(filepath)] + extra_args
48
+ # '-m live' re-enables the live tier, which pytest.ini deselects by default.
49
+ [sys.executable, "-m", "pytest", "-m", "live", str(filepath)] + extra_args
49
50
  )
50
51
  results[filename] = rc
51
52
 
@@ -311,22 +311,33 @@ class TestGetChannels:
311
311
  assert isinstance(result, list)
312
312
  _pause()
313
313
 
314
- def test_active_only_true_subset_of_all(self, ctx, users):
315
- """active_only=True should return ≤ active_only=False."""
316
- active = users.get_channels(ctx.active_member_id, active_only=True)
314
+ def test_current_only_subset_of_including_left(self, ctx, users):
315
+ """Default (current only) should return ≤ include_channels_user_left=True."""
316
+ current = users.get_channels(ctx.active_member_id)
317
317
  _pause()
318
- all_ch = users.get_channels(ctx.active_member_id, active_only=False)
318
+ including_left = users.get_channels(ctx.active_member_id, include_channels_user_left=True)
319
319
  _pause()
320
- assert len(active) <= len(all_ch), (
321
- f"active_only ({len(active)}) should be ≤ all ({len(all_ch)})"
320
+ assert len(current) <= len(including_left), (
321
+ f"current only ({len(current)}) should be ≤ including left ({len(including_left)})"
322
322
  )
323
323
 
324
- def test_active_only_false_returns_list(self, ctx, users):
325
- """active_only=False should still return a list."""
326
- result = users.get_channels(ctx.active_member_id, active_only=False)
324
+ def test_including_left_returns_list(self, ctx, users):
325
+ """include_channels_user_left=True should still return a list."""
326
+ result = users.get_channels(ctx.active_member_id, include_channels_user_left=True)
327
327
  assert isinstance(result, list)
328
328
  _pause()
329
329
 
330
+ def test_default_excludes_left_channels(self, ctx, users):
331
+ """Default call must not return channels the user has left.
332
+
333
+ Guards the regression where include_historical was never sent: the flag
334
+ controls the request, so a default call should contain no date_left != 0.
335
+ """
336
+ result = users.get_channels(ctx.active_member_id)
337
+ left = [c for c in result if c.get("date_left", 0) != 0]
338
+ assert not left, f"Default call returned {len(left)} channel(s) the user left"
339
+ _pause()
340
+
330
341
 
331
342
  # ═══════════════════════════════════════════════════════════════════════════
332
343
  # 6. refresh
@@ -0,0 +1,31 @@
1
+ """
2
+ Shared pytest configuration for the whole test suite.
3
+
4
+ The suite has two tiers:
5
+
6
+ Offline tier - tests/UnitTests/* (fakes only, no network, no credentials)
7
+ Live tier - tests/**/*_live.py (real Azure Key Vault tokens + real Slack API calls)
8
+
9
+ Every live test file follows the '*_live.py' naming convention, so instead of
10
+ decorating hundreds of tests by hand, this hook marks them automatically at
11
+ collection time.
12
+
13
+ pytest.ini deselects the 'live' marker by default, which makes a bare 'pytest'
14
+ run safe: it collects only the offline tier. The live tier is opt-in, since some
15
+ of it (notably tests/SCIM) performs mutating operations against real users.
16
+
17
+ To run the live tier explicitly:
18
+
19
+ pytest -m live tests/Users/test_users_read_live.py
20
+ python tests/Users/run_all_users_live_tests.py
21
+ """
22
+
23
+ import pytest
24
+
25
+
26
+ def pytest_collection_modifyitems(config, items):
27
+ """Automatically apply the 'live' marker to tests in *_live.py files."""
28
+ for item in items:
29
+ # item.path is the file the test was collected from.
30
+ if item.path is not None and item.path.name.endswith("_live.py"):
31
+ item.add_marker(pytest.mark.live)
@@ -1,3 +0,0 @@
1
- [pytest]
2
- pythonpath =
3
- src
File without changes
File without changes
File without changes