icloud-cli-tools 0.1.2__tar.gz → 0.1.3__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 (32) hide show
  1. {icloud_cli_tools-0.1.2/src/icloud_cli_tools.egg-info → icloud_cli_tools-0.1.3}/PKG-INFO +3 -2
  2. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/README.md +2 -1
  3. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/pyproject.toml +1 -1
  4. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/__init__.py +1 -1
  5. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/auth.py +44 -20
  6. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/main.py +7 -1
  7. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/reminders.py +191 -38
  8. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3/src/icloud_cli_tools.egg-info}/PKG-INFO +3 -2
  9. icloud_cli_tools-0.1.3/tests/test_auth.py +207 -0
  10. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_reminders.py +202 -0
  11. icloud_cli_tools-0.1.2/tests/test_auth.py +0 -97
  12. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/.github/workflows/ci.yml +0 -0
  13. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/.gitignore +0 -0
  14. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/LICENSE +0 -0
  15. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/setup.cfg +0 -0
  16. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/config.py +0 -0
  17. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/daemon.py +0 -0
  18. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/output.py +0 -0
  19. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/__init__.py +0 -0
  20. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/calendar.py +0 -0
  21. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/findmy.py +0 -0
  22. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli/services/notes.py +0 -0
  23. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/SOURCES.txt +0 -0
  24. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/dependency_links.txt +0 -0
  25. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/entry_points.txt +0 -0
  26. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/requires.txt +0 -0
  27. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/src/icloud_cli_tools.egg-info/top_level.txt +0 -0
  28. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/systemd/icloud-cli-sync.service +0 -0
  29. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/conftest.py +0 -0
  30. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_calendar.py +0 -0
  31. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_findmy.py +0 -0
  32. {icloud_cli_tools-0.1.2 → icloud_cli_tools-0.1.3}/tests/test_notes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icloud-cli-tools
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Access iCloud Calendar, Reminders, Notes, and Find My from Linux
5
5
  Author: alan
6
6
  License-Expression: MIT
@@ -120,7 +120,8 @@ icloud-cli calendar delete <event-id>
120
120
  ```bash
121
121
  icloud-cli reminders list # All active reminders
122
122
  icloud-cli reminders list --list "Shopping" --completed
123
- icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping"
123
+ icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping" # bare date -> all-day reminder
124
+ icloud-cli reminders add -t "Standup" -d "2025-06-15 09:30" # date + time -> timed reminder
124
125
  icloud-cli reminders complete <reminder-id>
125
126
  icloud-cli reminders delete <reminder-id>
126
127
  ```
@@ -84,7 +84,8 @@ icloud-cli calendar delete <event-id>
84
84
  ```bash
85
85
  icloud-cli reminders list # All active reminders
86
86
  icloud-cli reminders list --list "Shopping" --completed
87
- icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping"
87
+ icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping" # bare date -> all-day reminder
88
+ icloud-cli reminders add -t "Standup" -d "2025-06-15 09:30" # date + time -> timed reminder
88
89
  icloud-cli reminders complete <reminder-id>
89
90
  icloud-cli reminders delete <reminder-id>
90
91
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "icloud-cli-tools"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Access iCloud Calendar, Reminders, Notes, and Find My from Linux"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """icloud-cli-tools: Access iCloud services from Linux."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
@@ -87,46 +87,70 @@ class AuthManager:
87
87
  return True
88
88
 
89
89
  def _handle_2fa(self) -> bool:
90
- """Handle two-factor authentication (trusted device code)."""
90
+ """Handle two-factor authentication (HSA2 trusted device or SMS code)."""
91
91
  info("Two-factor authentication required.")
92
92
 
93
93
  try:
94
- self._api.request_2fa_code()
94
+ delivered = self._api.request_2fa_code()
95
95
  except Exception as e:
96
96
  error(f"Failed to trigger 2FA delivery: {e}")
97
97
  return False
98
98
 
99
+ if not delivered:
100
+ # Don't prompt for a code that never arrived. pyicloud identifies
101
+ # hardware-key challenges explicitly; a false return with any
102
+ # other method means Apple offered no supported delivery channel.
103
+ if self._api.two_factor_delivery_method == "security_key":
104
+ error(
105
+ "This 2FA challenge requires a hardware security key, "
106
+ "which icloud-cli does not support."
107
+ )
108
+ else:
109
+ error(
110
+ "Apple did not offer a supported 2FA code-delivery channel. "
111
+ "Check that your account has a trusted device or phone number."
112
+ )
113
+ return False
114
+
115
+ # Describe the channel we'll actually validate against. validate_2fa_code()
116
+ # keys off two_factor_delivery_method, so the prompt must match it — telling
117
+ # the user to enter the SMS code when we're verifying the trusted-device
118
+ # code (or vice versa) is what made the old prompt misleading.
99
119
  method = self._api.two_factor_delivery_method
100
120
  notice = self._api.two_factor_delivery_notice
101
121
 
102
- if method == "trusted_device":
103
- info("Approve the sign-in request on your trusted device.")
104
- info("Enter the 6-digit code shown after you approve.")
105
- elif method == "sms":
106
- info("A verification code has been sent via SMS.")
122
+ if method == "sms":
123
+ info("Apple sent a 6-digit verification code by SMS.")
124
+ info("Enter the code from the text message.")
107
125
  else:
108
- info("A verification code has been sent to your trusted devices.")
126
+ info("Apple sent a 6-digit verification code to your trusted devices.")
127
+ info("Enter the code shown in the trusted-device notification.")
109
128
 
110
129
  if notice:
111
130
  warning(notice)
112
131
 
113
- code = click.prompt("Enter 2FA code")
132
+ max_attempts = 3
133
+ for attempt in range(max_attempts):
134
+ code = click.prompt("Enter 2FA code")
135
+ try:
136
+ if self._api.validate_2fa_code(code):
137
+ break
138
+ except Exception as e:
139
+ error(f"2FA verification failed: {e}")
140
+ return False
114
141
 
115
- try:
116
- result = self._api.validate_2fa_code(code)
117
- if not result:
142
+ remaining = max_attempts - attempt - 1
143
+ if remaining <= 0:
118
144
  error("Invalid 2FA code.")
119
145
  return False
146
+ warning(f"Invalid 2FA code. {remaining} attempt(s) remaining.")
120
147
 
121
- if not self._api.is_trusted_session:
122
- info("Trusting this session...")
123
- self._api.trust_session()
148
+ if not self._api.is_trusted_session:
149
+ info("Trusting this session...")
150
+ self._api.trust_session()
124
151
 
125
- success("2FA verification successful!")
126
- return True
127
- except Exception as e:
128
- error(f"2FA verification failed: {e}")
129
- return False
152
+ success("2FA verification successful!")
153
+ return True
130
154
 
131
155
  def _handle_2sa(self) -> bool:
132
156
  """Handle two-step authentication (SMS/phone-based)."""
@@ -206,7 +206,13 @@ def reminders_list(ctx: AppContext, list_name: str | None, completed: bool):
206
206
 
207
207
  @reminders.command("add")
208
208
  @click.option("--title", "-t", required=True, help="Reminder title.")
209
- @click.option("--due", "-d", default=None, help="Due date (YYYY-MM-DD or YYYY-MM-DD HH:MM).")
209
+ @click.option(
210
+ "--due",
211
+ "-d",
212
+ default=None,
213
+ help="Due date. A bare date (YYYY-MM-DD) creates an all-day reminder; "
214
+ "add a time (YYYY-MM-DD HH:MM) for a timed reminder.",
215
+ )
210
216
  @click.option("--list", "-l", "list_name", default=None, help="Reminder list name.")
211
217
  @click.option("--description", default=None, help="Reminder description.")
212
218
  @pass_context
@@ -1,6 +1,10 @@
1
1
  """Reminders service for icloud-cli.
2
2
 
3
3
  Provides CRUD operations for iCloud Reminders via pyicloud.
4
+
5
+ Supports cursor-based incremental sync: the first call does a full CloudKit sync.
6
+ Subsequent calls use iter_changes(since=cursor) to fetch only changes.
7
+ State is persisted under config.cache_dir/reminders/.
4
8
  """
5
9
 
6
10
  from __future__ import annotations
@@ -8,7 +12,9 @@ from __future__ import annotations
8
12
  import json
9
13
  import time
10
14
  import uuid
15
+ from contextlib import suppress
11
16
  from datetime import datetime
17
+ from pathlib import Path
12
18
  from typing import Any
13
19
 
14
20
  from dateutil import parser as dateutil_parser
@@ -25,12 +31,20 @@ class RemindersService:
25
31
  self.config = config
26
32
  self._reminders_service = api.reminders
27
33
 
34
+ # Cursor-based incremental sync state
35
+ self._cache_dir = Path(config.cache_dir) / "reminders"
36
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
37
+ self._state_file = self._cache_dir / "state.json"
38
+
28
39
  def list_reminders(
29
40
  self,
30
41
  list_name: str | None = None,
31
42
  show_completed: bool = False,
32
43
  ) -> list[dict[str, Any]]:
33
- """List reminders, optionally filtered by list.
44
+ """List reminders with cursor-based incremental sync.
45
+
46
+ First call triggers a full CloudKit sync (~5 min for 200 reminders).
47
+ Subsequent calls fetch only changes since the last cursor (~1s).
34
48
 
35
49
  Args:
36
50
  list_name: Filter to specific reminder list.
@@ -39,45 +53,122 @@ class RemindersService:
39
53
  Returns:
40
54
  List of reminder dictionaries.
41
55
  """
56
+
57
+ state = self._load_sync_state()
58
+
42
59
  try:
43
- lists = list(self._reminders_service.lists())
60
+ rlists = list(self._reminders_service.lists())
61
+ list_map = {lst.id: lst.title for lst in rlists}
62
+
63
+ # Capture an upper bound before reading any reminder records. If a
64
+ # change arrives while the read is in progress, keeping this older
65
+ # cursor makes the next invocation replay that change safely.
66
+ next_cursor = self._reminders_service.sync_cursor()
67
+
68
+ if state is None:
69
+ data_map = self._full_reminder_snapshot(rlists, list_map)
70
+ else:
71
+ cursor, cached_data = state
72
+ data_map = {entry["id"]: entry for entry in cached_data}
73
+
74
+ # List changes are not emitted by iter_changes(), so refresh
75
+ # cached display names from the lightweight list snapshot.
76
+ for entry in data_map.values():
77
+ entry_list_id = entry.get("list_id")
78
+ if entry_list_id:
79
+ entry["list"] = list_map.get(entry_list_id, entry_list_id)
80
+
81
+ try:
82
+ for change in self._reminders_service.iter_changes(since=cursor):
83
+ reminder_id = change.reminder_id
84
+ reminder = change.reminder
85
+ if change.type == "deleted" or not reminder or reminder.deleted:
86
+ data_map.pop(reminder_id, None)
87
+ continue
88
+
89
+ list_title = list_map.get(reminder.list_id, reminder.list_id)
90
+ data_map[reminder_id] = self._reminder_to_entry(
91
+ reminder, list_title
92
+ )
93
+ except Exception:
94
+ # An expired or account-mismatched cursor requires a fresh
95
+ # snapshot. next_cursor was captured before this read, so
96
+ # later changes will still be replayed on the next call.
97
+ data_map = self._full_reminder_snapshot(rlists, list_map)
44
98
  except Exception as e:
45
99
  from icloud_cli.output import error
46
100
  error(f"Failed to fetch reminders: {e}")
47
101
  return []
48
102
 
49
- result = []
50
- for rlist in lists:
51
- rlist_title = rlist.title
52
- rlist_id = rlist.id
103
+ cached_data = list(data_map.values())
104
+ # The old state remains intact on failure because replacement is
105
+ # atomic, so the next invocation replays from its older cursor.
106
+ with suppress(OSError):
107
+ self._write_sync_state(next_cursor, cached_data)
53
108
 
54
- if list_name and rlist_title.lower() != list_name.lower():
109
+ result = []
110
+ for cached_entry in cached_data:
111
+ entry = {key: value for key, value in cached_entry.items() if key != "list_id"}
112
+ if list_name and entry["list"].lower() != list_name.lower():
55
113
  continue
114
+ if not show_completed and entry["completed"]:
115
+ continue
116
+ result.append(entry)
117
+ return result
56
118
 
57
- reminders = list(self._reminders_service.reminders(list_id=rlist_id))
119
+ def _load_sync_state(self) -> tuple[str, list[dict[str, Any]]] | None:
120
+ """Load a complete cursor/data generation, or request a full sync."""
121
+ if not self._state_file.exists():
122
+ return None
123
+ try:
124
+ state = json.loads(self._state_file.read_text())
125
+ if state.get("version") != 1 or state.get("account") != self.config.apple_id:
126
+ return None
127
+ cursor = state["cursor"]
128
+ entries = state["entries"]
129
+ entries_are_valid = isinstance(entries, list) and all(
130
+ isinstance(entry, dict)
131
+ and isinstance(entry.get("id"), str)
132
+ and isinstance(entry.get("list_id"), str)
133
+ for entry in entries
134
+ )
135
+ if not isinstance(cursor, str) or not entries_are_valid:
136
+ return None
137
+ return cursor, entries
138
+ except (json.JSONDecodeError, KeyError, OSError, TypeError, AttributeError):
139
+ return None
58
140
 
59
- for reminder in reminders:
60
- if not show_completed and reminder.completed:
141
+ def _write_sync_state(self, cursor: str, entries: list[dict[str, Any]]) -> None:
142
+ """Atomically persist cursor and entries as one cache generation."""
143
+ state = {
144
+ "version": 1,
145
+ "account": self.config.apple_id,
146
+ "cursor": cursor,
147
+ "entries": entries,
148
+ }
149
+ temp_file = self._state_file.with_name(
150
+ f".{self._state_file.name}.{uuid.uuid4().hex}.tmp"
151
+ )
152
+ try:
153
+ temp_file.write_text(json.dumps(state, indent=2))
154
+ temp_file.replace(self._state_file)
155
+ finally:
156
+ if temp_file.exists():
157
+ temp_file.unlink()
158
+
159
+ def _full_reminder_snapshot(
160
+ self, rlists: list[Any], list_map: dict[str, str]
161
+ ) -> dict[str, dict[str, Any]]:
162
+ """Fetch a complete deduplicated reminder snapshot."""
163
+ data_map: dict[str, dict[str, Any]] = {}
164
+ for rlist in rlists:
165
+ for reminder in self._reminders_service.reminders(list_id=rlist.id):
166
+ if getattr(reminder, "deleted", False):
61
167
  continue
62
-
63
- due_date = ""
64
- if reminder.due_date:
65
- due_date = _format_due_date(reminder.due_date)
66
-
67
- priority_map = {0: "", 1: "High", 5: "Medium", 9: "Low"}
68
- priority = priority_map.get(reminder.priority, "")
69
-
70
- result.append({
71
- "id": reminder.id,
72
- "title": reminder.title,
73
- "list": rlist_title,
74
- "due_date": due_date,
75
- "priority": priority,
76
- "completed": "✓" if reminder.completed else "",
77
- "description": reminder.desc,
78
- })
79
-
80
- return result
168
+ data_map[reminder.id] = self._reminder_to_entry(
169
+ reminder, list_map.get(reminder.list_id, reminder.list_id)
170
+ )
171
+ return data_map
81
172
 
82
173
  def list_reminder_lists(self) -> list[dict[str, Any]]:
83
174
  """List reminder lists."""
@@ -249,12 +340,15 @@ class RemindersService:
249
340
  error("No reminder lists found.")
250
341
  return False
251
342
 
252
- # Parse due date
343
+ # Parse due date. When the user gives a bare date with no time
344
+ # (e.g. "2025-06-15"), create an all-day reminder so it shows the
345
+ # date without a "12:00 AM" time, matching the Reminders app.
253
346
  due: datetime | None = None
347
+ all_day = False
254
348
  if due_date:
255
349
  try:
256
- due = dateutil_parser.parse(due_date)
257
- except (ValueError, TypeError):
350
+ due, all_day = _parse_due_date(due_date)
351
+ except (ValueError, TypeError, OverflowError):
258
352
  from icloud_cli.output import error
259
353
  error("Invalid date format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM.")
260
354
  return False
@@ -264,6 +358,7 @@ class RemindersService:
264
358
  title=title,
265
359
  desc=description or "",
266
360
  due_date=due,
361
+ all_day=all_day,
267
362
  priority=0,
268
363
  )
269
364
  return True
@@ -273,6 +368,27 @@ class RemindersService:
273
368
  error(f"Failed to create reminder: {e}")
274
369
  return False
275
370
 
371
+ @staticmethod
372
+ def _reminder_to_entry(r, list_title: str) -> dict[str, Any]:
373
+ """Convert a pyicloud Reminder model to the icloud-cli dict format."""
374
+ priority_map = {0: "", 1: "High", 5: "Medium", 9: "Low"}
375
+ due_date = ""
376
+ if r.due_date:
377
+ due_date = _format_due_date(
378
+ r.due_date,
379
+ date_only=bool(getattr(r, "all_day", False)),
380
+ )
381
+ return {
382
+ "id": r.id,
383
+ "list_id": r.list_id,
384
+ "title": r.title,
385
+ "list": list_title,
386
+ "due_date": due_date,
387
+ "priority": priority_map.get(r.priority, ""),
388
+ "completed": "✓" if r.completed else "",
389
+ "description": r.desc,
390
+ }
391
+
276
392
  def _find_list_id(self, list_name: str) -> str | None:
277
393
  for rlist in self._reminders_service.lists():
278
394
  if rlist.title.lower() == list_name.lower() or rlist.id == list_name:
@@ -350,29 +466,66 @@ class RemindersService:
350
466
  return False
351
467
 
352
468
 
353
- def _format_due_date(due_date: Any) -> str:
354
- """Format a due date from the API response."""
469
+ def _parse_due_date(due_date: str) -> tuple[datetime, bool]:
470
+ """Parse a due-date string, detecting whether a time was supplied.
471
+
472
+ Returns a ``(datetime, all_day)`` tuple. When the input carries no time
473
+ component the reminder is treated as all-day and anchored to noon so the
474
+ stored calendar date stays stable across time zones (pyicloud persists a
475
+ naive datetime as UTC).
476
+
477
+ Date fields the user omits fall back to today, matching ``dateutil``'s
478
+ default. The two parse defaults share the same date and differ only in
479
+ their time fields, so a time the user actually typed resolves identically
480
+ against both while a time left to the default does not -- that is what
481
+ distinguishes a bare date from a timed one.
482
+ """
483
+ today = datetime.now()
484
+ default_a = today.replace(hour=0, minute=0, second=0, microsecond=0)
485
+ default_b = today.replace(hour=23, minute=59, second=58, microsecond=0)
486
+ parsed_a = dateutil_parser.parse(due_date, default=default_a)
487
+ parsed_b = dateutil_parser.parse(due_date, default=default_b)
488
+ has_time = (
489
+ parsed_a.hour == parsed_b.hour
490
+ or parsed_a.minute == parsed_b.minute
491
+ or parsed_a.second == parsed_b.second
492
+ )
493
+ if has_time:
494
+ return parsed_a, False
495
+ anchored = parsed_a.replace(hour=12, minute=0, second=0, microsecond=0)
496
+ return anchored, True
497
+
498
+
499
+ def _format_due_date(due_date: Any, date_only: bool = False) -> str:
500
+ """Format a due date from the API response.
501
+
502
+ When ``date_only`` is True (all-day reminders), the time component is
503
+ omitted so the reminder shows just its date.
504
+ """
505
+ fmt = "%Y-%m-%d" if date_only else "%Y-%m-%d %H:%M"
355
506
  if isinstance(due_date, str):
356
507
  try:
357
508
  dt = dateutil_parser.parse(due_date)
358
- return dt.strftime("%Y-%m-%d %H:%M")
509
+ return dt.strftime(fmt)
359
510
  except (ValueError, TypeError):
360
511
  return due_date
361
- if isinstance(due_date, list) and len(due_date) >= 4:
512
+ if isinstance(due_date, list) and len(due_date) >= 5:
362
513
  # iCloud format: [year, month, day, hour, minute]
363
514
  try:
364
515
  date_part = f"{due_date[0]:04d}-{due_date[1]:02d}-{due_date[2]:02d}"
516
+ if date_only:
517
+ return date_part
365
518
  time_part = f"{due_date[3]:02d}:{due_date[4]:02d}"
366
519
  return f"{date_part} {time_part}"
367
520
  except (IndexError, TypeError):
368
521
  return str(due_date)
369
522
  if isinstance(due_date, (int, float)):
370
523
  try:
371
- return datetime.fromtimestamp(due_date / 1000).strftime("%Y-%m-%d %H:%M")
524
+ return datetime.fromtimestamp(due_date / 1000).strftime(fmt)
372
525
  except (ValueError, OSError):
373
526
  return str(due_date)
374
527
  if isinstance(due_date, datetime):
375
- return due_date.strftime("%Y-%m-%d %H:%M")
528
+ return due_date.strftime(fmt)
376
529
  return str(due_date)
377
530
 
378
531
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icloud-cli-tools
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Access iCloud Calendar, Reminders, Notes, and Find My from Linux
5
5
  Author: alan
6
6
  License-Expression: MIT
@@ -120,7 +120,8 @@ icloud-cli calendar delete <event-id>
120
120
  ```bash
121
121
  icloud-cli reminders list # All active reminders
122
122
  icloud-cli reminders list --list "Shopping" --completed
123
- icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping"
123
+ icloud-cli reminders add -t "Buy milk" -d "2025-06-15" -l "Shopping" # bare date -> all-day reminder
124
+ icloud-cli reminders add -t "Standup" -d "2025-06-15 09:30" # date + time -> timed reminder
124
125
  icloud-cli reminders complete <reminder-id>
125
126
  icloud-cli reminders delete <reminder-id>
126
127
  ```
@@ -0,0 +1,207 @@
1
+ """Tests for the auth module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from icloud_cli.auth import AuthManager
8
+ from icloud_cli.config import Config
9
+
10
+
11
+ class TestAuthManager:
12
+ """Tests for AuthManager."""
13
+
14
+ def test_get_status_not_logged_in(self, tmp_path):
15
+ """Status shows not logged in when no credentials stored."""
16
+ config = Config(
17
+ apple_id="",
18
+ session_dir=str(tmp_path / "session"),
19
+ config_file=tmp_path / "config.toml",
20
+ )
21
+ auth = AuthManager(config)
22
+ status = auth.get_status()
23
+
24
+ assert status["apple_id"] == "(not set)"
25
+ assert status["password_stored"] == "No"
26
+ assert status["session_cached"] == "No"
27
+
28
+ @patch("icloud_cli.auth.keyring")
29
+ def test_get_status_logged_in(self, mock_keyring, tmp_path):
30
+ """Status shows logged in when credentials are stored."""
31
+ config = Config(
32
+ apple_id="test@icloud.com",
33
+ session_dir=str(tmp_path / "session"),
34
+ config_file=tmp_path / "config.toml",
35
+ )
36
+ # Create a session file
37
+ session_dir = tmp_path / "session"
38
+ session_dir.mkdir(parents=True)
39
+ (session_dir / "session_cookie").write_text("fake_cookie")
40
+
41
+ mock_keyring.get_password.return_value = "stored_password"
42
+
43
+ auth = AuthManager(config)
44
+ status = auth.get_status()
45
+
46
+ assert status["apple_id"] == "test@icloud.com"
47
+ assert status["password_stored"] == "Yes"
48
+ assert status["session_cached"] == "Yes"
49
+
50
+ @patch("icloud_cli.auth.keyring")
51
+ def test_logout_clears_session(self, mock_keyring, tmp_path):
52
+ """Logout clears session files."""
53
+ config = Config(
54
+ apple_id="test@icloud.com",
55
+ session_dir=str(tmp_path / "session"),
56
+ config_file=tmp_path / "config.toml",
57
+ )
58
+ session_dir = tmp_path / "session"
59
+ session_dir.mkdir(parents=True)
60
+ (session_dir / "cookie1").write_text("data")
61
+ (session_dir / "cookie2").write_text("data")
62
+
63
+ mock_keyring.delete_password.return_value = None
64
+
65
+ auth = AuthManager(config)
66
+ auth.logout()
67
+
68
+ # Session files should be cleared
69
+ assert list(session_dir.iterdir()) == []
70
+
71
+ @patch("icloud_cli.auth.keyring")
72
+ def test_has_no_cached_session(self, mock_keyring, tmp_path):
73
+ """Reports no session when session dir is empty."""
74
+ config = Config(
75
+ apple_id="test@icloud.com",
76
+ session_dir=str(tmp_path / "session"),
77
+ config_file=tmp_path / "config.toml",
78
+ )
79
+ (tmp_path / "session").mkdir(parents=True)
80
+
81
+ mock_keyring.get_password.return_value = None
82
+
83
+ auth = AuthManager(config)
84
+ assert not auth._has_cached_session()
85
+
86
+ @patch("icloud_cli.auth.keyring")
87
+ def test_imap_credentials_not_set(self, mock_keyring, tmp_path):
88
+ """Returns None when IMAP credentials are not configured."""
89
+ config = Config(
90
+ apple_id="test@icloud.com",
91
+ session_dir=str(tmp_path / "session"),
92
+ config_file=tmp_path / "config.toml",
93
+ )
94
+ mock_keyring.get_password.return_value = None
95
+
96
+ auth = AuthManager(config)
97
+ assert auth.get_imap_credentials() is None
98
+
99
+
100
+ class TestHandle2FA:
101
+ """Tests for the interactive 2FA prompt flow."""
102
+
103
+ def _make_auth(self, tmp_path, api):
104
+ config = Config(
105
+ apple_id="test@icloud.com",
106
+ session_dir=str(tmp_path / "session"),
107
+ config_file=tmp_path / "config.toml",
108
+ )
109
+ auth = AuthManager(config)
110
+ auth._api = api
111
+ return auth
112
+
113
+ @patch("icloud_cli.auth.click")
114
+ def test_security_key_challenge_does_not_prompt(self, mock_click, tmp_path):
115
+ """When request_2fa_code() returns False, no code is requested."""
116
+ api = MagicMock()
117
+ api.request_2fa_code.return_value = False
118
+ api.two_factor_delivery_method = "security_key"
119
+ auth = self._make_auth(tmp_path, api)
120
+
121
+ assert auth._handle_2fa() is False
122
+ api.request_2fa_code.assert_called_once()
123
+ # No code should be prompted for or validated when nothing was delivered.
124
+ mock_click.prompt.assert_not_called()
125
+ api.validate_2fa_code.assert_not_called()
126
+
127
+ @patch("icloud_cli.auth.error")
128
+ @patch("icloud_cli.auth.click")
129
+ def test_missing_delivery_channel_has_accurate_error(
130
+ self, mock_click, mock_error, tmp_path
131
+ ):
132
+ """A missing delivery route is not misreported as a security-key challenge."""
133
+ api = MagicMock()
134
+ api.request_2fa_code.return_value = False
135
+ api.two_factor_delivery_method = "unknown"
136
+ auth = self._make_auth(tmp_path, api)
137
+
138
+ assert auth._handle_2fa() is False
139
+ message = str(mock_error.call_args.args[0]).lower()
140
+ assert "delivery channel" in message
141
+ assert "hardware security key" not in message
142
+ mock_click.prompt.assert_not_called()
143
+
144
+ @patch("icloud_cli.auth.click")
145
+ def test_trusted_device_success(self, mock_click, tmp_path):
146
+ """A valid trusted-device code trusts the session and succeeds."""
147
+ api = MagicMock()
148
+ api.request_2fa_code.return_value = True
149
+ api.two_factor_delivery_method = "trusted_device"
150
+ api.two_factor_delivery_notice = None
151
+ api.validate_2fa_code.return_value = True
152
+ api.is_trusted_session = False
153
+ auth = self._make_auth(tmp_path, api)
154
+ mock_click.prompt.return_value = "123456"
155
+
156
+ assert auth._handle_2fa() is True
157
+ api.validate_2fa_code.assert_called_once_with("123456")
158
+ api.trust_session.assert_called_once()
159
+
160
+ @patch("icloud_cli.auth.info")
161
+ @patch("icloud_cli.auth.click")
162
+ def test_prompt_text_matches_delivery_channel(self, mock_click, mock_info, tmp_path):
163
+ """The prompt describes the trusted-device channel without an approve step."""
164
+ api = MagicMock()
165
+ api.request_2fa_code.return_value = True
166
+ api.two_factor_delivery_method = "trusted_device"
167
+ api.two_factor_delivery_notice = None
168
+ api.validate_2fa_code.return_value = True
169
+ api.is_trusted_session = True
170
+ auth = self._make_auth(tmp_path, api)
171
+ mock_click.prompt.return_value = "123456"
172
+
173
+ auth._handle_2fa()
174
+
175
+ messages = " ".join(str(c.args[0]) for c in mock_info.call_args_list).lower()
176
+ assert "approve" not in messages
177
+ assert "trusted device" in messages
178
+
179
+ @patch("icloud_cli.auth.click")
180
+ def test_retries_then_succeeds(self, mock_click, tmp_path):
181
+ """A mistyped code is retried instead of forcing a re-login."""
182
+ api = MagicMock()
183
+ api.request_2fa_code.return_value = True
184
+ api.two_factor_delivery_method = "sms"
185
+ api.two_factor_delivery_notice = None
186
+ api.validate_2fa_code.side_effect = [False, True]
187
+ api.is_trusted_session = True
188
+ auth = self._make_auth(tmp_path, api)
189
+ mock_click.prompt.return_value = "000000"
190
+
191
+ assert auth._handle_2fa() is True
192
+ assert api.validate_2fa_code.call_count == 2
193
+
194
+ @patch("icloud_cli.auth.click")
195
+ def test_invalid_code_exhausts_attempts(self, mock_click, tmp_path):
196
+ """After three wrong codes the flow fails without trusting the session."""
197
+ api = MagicMock()
198
+ api.request_2fa_code.return_value = True
199
+ api.two_factor_delivery_method = "trusted_device"
200
+ api.two_factor_delivery_notice = None
201
+ api.validate_2fa_code.return_value = False
202
+ auth = self._make_auth(tmp_path, api)
203
+ mock_click.prompt.return_value = "000000"
204
+
205
+ assert auth._handle_2fa() is False
206
+ assert api.validate_2fa_code.call_count == 3
207
+ api.trust_session.assert_not_called()
@@ -5,9 +5,12 @@ from __future__ import annotations
5
5
  from datetime import datetime
6
6
  from unittest.mock import MagicMock, patch
7
7
 
8
+ import pytest
9
+
8
10
  from icloud_cli.services.reminders import (
9
11
  RemindersService,
10
12
  _format_due_date,
13
+ _parse_due_date,
11
14
  _reminders_color_payload,
12
15
  )
13
16
 
@@ -51,14 +54,53 @@ class TestFormatDueDate:
51
54
  result = _format_due_date(None)
52
55
  assert result == "None"
53
56
 
57
+ def test_format_date_only_omits_time(self):
58
+ result = _format_due_date(datetime(2025, 6, 15, 14, 30), date_only=True)
59
+ assert result == "2025-06-15"
60
+
61
+ def test_format_date_only_list(self):
62
+ result = _format_due_date([2025, 6, 15, 14, 30], date_only=True)
63
+ assert result == "2025-06-15"
64
+
54
65
  def test_color_payload_defaults_unknown_color_to_blue(self):
55
66
  assert _reminders_color_payload("wat") == _reminders_color_payload("blue")
56
67
 
57
68
 
69
+ class TestParseDueDate:
70
+ """Tests for due-date parsing and all-day detection."""
71
+
72
+ def test_bare_date_is_all_day_anchored_to_noon(self):
73
+ due, all_day = _parse_due_date("2025-06-15")
74
+ assert all_day is True
75
+ assert (due.year, due.month, due.day) == (2025, 6, 15)
76
+ assert (due.hour, due.minute, due.second) == (12, 0, 0)
77
+
78
+ def test_date_with_time_is_not_all_day(self):
79
+ due, all_day = _parse_due_date("2025-06-15 14:30")
80
+ assert all_day is False
81
+ assert (due.hour, due.minute) == (14, 30)
82
+
83
+ def test_explicit_midnight_is_not_all_day(self):
84
+ # A user who explicitly types 00:00 wants a timed reminder.
85
+ due, all_day = _parse_due_date("2025-06-15 00:00")
86
+ assert all_day is False
87
+ assert (due.hour, due.minute) == (0, 0)
88
+
89
+ def test_year_omitted_defaults_to_current_year(self):
90
+ # Regression: a date without a year should fall back to the current
91
+ # year, not a hardcoded one.
92
+ due, all_day = _parse_due_date("June 15")
93
+ assert all_day is True
94
+ assert due.year == datetime.now().year
95
+ assert (due.month, due.day) == (6, 15)
96
+
97
+
58
98
  class TestRemindersService:
59
99
  """Tests for RemindersService with mocked API."""
60
100
 
61
101
  def _make_service(self, mock_api, mock_config):
102
+ mock_api.reminders.sync_cursor.return_value = "cursor-1"
103
+ mock_api.reminders.iter_changes.return_value = []
62
104
  return RemindersService(mock_api, mock_config)
63
105
 
64
106
  def _make_list_model(self, list_id="list-1", title="Personal"):
@@ -83,8 +125,17 @@ class TestRemindersService:
83
125
  r.desc = desc
84
126
  r.list_id = "list-1"
85
127
  r.flagged = False
128
+ r.all_day = False
129
+ r.deleted = False
86
130
  return r
87
131
 
132
+ def _make_change(self, reminder=None, change_type="updated", reminder_id=None):
133
+ change = MagicMock()
134
+ change.type = change_type
135
+ change.reminder = reminder
136
+ change.reminder_id = reminder_id or reminder.id
137
+ return change
138
+
88
139
  def test_list_reminders_empty(self, mock_api, mock_config):
89
140
  mock_api.reminders.lists.return_value = []
90
141
  mock_api.reminders.reminders.return_value = []
@@ -110,6 +161,32 @@ class TestRemindersService:
110
161
  assert result[0]["list"] == "Personal"
111
162
  assert result[0]["priority"] == "High"
112
163
 
164
+ def test_list_reminders_all_day_omits_time(self, mock_api, mock_config):
165
+ lst = self._make_list_model()
166
+ mock_api.reminders.lists.return_value = [lst]
167
+
168
+ reminder = self._make_reminder_model(due_date=datetime(2025, 6, 15, 12, 0))
169
+ reminder.all_day = True
170
+ mock_api.reminders.reminders.return_value = [reminder]
171
+
172
+ service = self._make_service(mock_api, mock_config)
173
+ result = service.list_reminders()
174
+
175
+ assert result[0]["due_date"] == "2025-06-15"
176
+
177
+ def test_list_reminders_timed_shows_time(self, mock_api, mock_config):
178
+ lst = self._make_list_model()
179
+ mock_api.reminders.lists.return_value = [lst]
180
+
181
+ reminder = self._make_reminder_model(due_date=datetime(2025, 6, 15, 9, 30))
182
+ reminder.all_day = False
183
+ mock_api.reminders.reminders.return_value = [reminder]
184
+
185
+ service = self._make_service(mock_api, mock_config)
186
+ result = service.list_reminders()
187
+
188
+ assert result[0]["due_date"] == "2025-06-15 09:30"
189
+
113
190
  def test_list_reminders_filters_completed(self, mock_api, mock_config):
114
191
  lst = self._make_list_model(list_id="list-1", title="Work")
115
192
  mock_api.reminders.lists.return_value = [lst]
@@ -133,6 +210,92 @@ class TestRemindersService:
133
210
  result = service.list_reminders(show_completed=True)
134
211
  assert len(result) == 2
135
212
 
213
+ def test_empty_snapshot_uses_incremental_sync_next_time(self, mock_api, mock_config):
214
+ mock_api.reminders.lists.return_value = []
215
+ service = self._make_service(mock_api, mock_config)
216
+
217
+ assert service.list_reminders() == []
218
+ assert service.list_reminders() == []
219
+
220
+ mock_api.reminders.iter_changes.assert_called_once_with(since="cursor-1")
221
+
222
+ def test_incremental_sync_applies_updates_and_deletions(self, mock_api, mock_config):
223
+ lst = self._make_list_model()
224
+ mock_api.reminders.lists.return_value = [lst]
225
+ old = self._make_reminder_model(reminder_id="rem-1", title="Old title")
226
+ removed = self._make_reminder_model(reminder_id="rem-2", title="Remove me")
227
+ mock_api.reminders.reminders.return_value = [old, removed]
228
+ service = self._make_service(mock_api, mock_config)
229
+ assert len(service.list_reminders()) == 2
230
+
231
+ updated = self._make_reminder_model(reminder_id="rem-1", title="New title")
232
+ mock_api.reminders.iter_changes.return_value = [
233
+ self._make_change(updated),
234
+ self._make_change(None, "deleted", "rem-2"),
235
+ ]
236
+
237
+ result = service.list_reminders()
238
+
239
+ assert [entry["title"] for entry in result] == ["New title"]
240
+
241
+ def test_list_rename_refreshes_cached_entries(self, mock_api, mock_config):
242
+ original_list = self._make_list_model(title="Personal")
243
+ mock_api.reminders.lists.return_value = [original_list]
244
+ mock_api.reminders.reminders.return_value = [self._make_reminder_model()]
245
+ service = self._make_service(mock_api, mock_config)
246
+ assert service.list_reminders()[0]["list"] == "Personal"
247
+
248
+ renamed_list = self._make_list_model(title="Renamed")
249
+ mock_api.reminders.lists.return_value = [renamed_list]
250
+
251
+ assert service.list_reminders()[0]["list"] == "Renamed"
252
+
253
+ def test_cursor_is_captured_before_incremental_read(self, mock_api, mock_config):
254
+ lst = self._make_list_model()
255
+ mock_api.reminders.lists.return_value = [lst]
256
+ mock_api.reminders.reminders.return_value = [self._make_reminder_model()]
257
+ service = self._make_service(mock_api, mock_config)
258
+ service.list_reminders()
259
+ mock_api.reminders.reset_mock()
260
+ mock_api.reminders.lists.return_value = [lst]
261
+ mock_api.reminders.sync_cursor.return_value = "cursor-2"
262
+ mock_api.reminders.iter_changes.return_value = []
263
+
264
+ service.list_reminders()
265
+
266
+ method_names = [call[0] for call in mock_api.reminders.method_calls]
267
+ assert method_names.index("sync_cursor") < method_names.index("iter_changes")
268
+
269
+ def test_invalid_cursor_falls_back_to_full_snapshot(self, mock_api, mock_config):
270
+ lst = self._make_list_model()
271
+ mock_api.reminders.lists.return_value = [lst]
272
+ original = self._make_reminder_model(title="Original")
273
+ mock_api.reminders.reminders.return_value = [original]
274
+ service = self._make_service(mock_api, mock_config)
275
+ service.list_reminders()
276
+
277
+ refreshed = self._make_reminder_model(title="Refreshed")
278
+ mock_api.reminders.reminders.return_value = [refreshed]
279
+ mock_api.reminders.iter_changes.side_effect = RuntimeError("expired cursor")
280
+
281
+ result = service.list_reminders()
282
+
283
+ assert result[0]["title"] == "Refreshed"
284
+
285
+ def test_atomic_state_failure_preserves_previous_generation(
286
+ self, mock_api, mock_config
287
+ ):
288
+ service = self._make_service(mock_api, mock_config)
289
+ service._write_sync_state("cursor-1", [])
290
+
291
+ with (
292
+ patch("pathlib.Path.replace", side_effect=OSError("disk full")),
293
+ pytest.raises(OSError),
294
+ ):
295
+ service._write_sync_state("cursor-2", [{"id": "rem-1"}])
296
+
297
+ assert service._load_sync_state() == ("cursor-1", [])
298
+
136
299
  def test_add_reminder_success(self, mock_api, mock_config):
137
300
  lst = self._make_list_model()
138
301
  mock_api.reminders.lists.return_value = [lst]
@@ -142,6 +305,45 @@ class TestRemindersService:
142
305
  assert service.add_reminder(title="New task") is True
143
306
  mock_api.reminders.create.assert_called_once()
144
307
 
308
+ def test_add_reminder_bare_date_is_all_day(self, mock_api, mock_config):
309
+ lst = self._make_list_model()
310
+ mock_api.reminders.lists.return_value = [lst]
311
+ mock_api.reminders.create.return_value = MagicMock()
312
+
313
+ service = self._make_service(mock_api, mock_config)
314
+ assert service.add_reminder(title="Pay rent", due_date="2025-06-15") is True
315
+
316
+ call_kwargs = mock_api.reminders.create.call_args[1]
317
+ assert call_kwargs["all_day"] is True
318
+ assert call_kwargs["due_date"].hour == 12
319
+ assert (
320
+ call_kwargs["due_date"].year,
321
+ call_kwargs["due_date"].month,
322
+ call_kwargs["due_date"].day,
323
+ ) == (2025, 6, 15)
324
+
325
+ def test_add_reminder_with_time_is_not_all_day(self, mock_api, mock_config):
326
+ lst = self._make_list_model()
327
+ mock_api.reminders.lists.return_value = [lst]
328
+ mock_api.reminders.create.return_value = MagicMock()
329
+
330
+ service = self._make_service(mock_api, mock_config)
331
+ assert (
332
+ service.add_reminder(title="Standup", due_date="2025-06-15 09:30") is True
333
+ )
334
+
335
+ call_kwargs = mock_api.reminders.create.call_args[1]
336
+ assert call_kwargs["all_day"] is False
337
+ assert (call_kwargs["due_date"].hour, call_kwargs["due_date"].minute) == (9, 30)
338
+
339
+ def test_add_reminder_invalid_date_returns_false(self, mock_api, mock_config):
340
+ lst = self._make_list_model()
341
+ mock_api.reminders.lists.return_value = [lst]
342
+
343
+ service = self._make_service(mock_api, mock_config)
344
+ assert service.add_reminder(title="Bad", due_date="not-a-date") is False
345
+ mock_api.reminders.create.assert_not_called()
346
+
145
347
  def test_add_reminder_with_list_name(self, mock_api, mock_config):
146
348
  lst1 = self._make_list_model(list_id="list-1", title="Personal")
147
349
  lst2 = self._make_list_model(list_id="list-2", title="Shopping")
@@ -1,97 +0,0 @@
1
- """Tests for the auth module."""
2
-
3
- from __future__ import annotations
4
-
5
- from unittest.mock import patch
6
-
7
- from icloud_cli.auth import AuthManager
8
- from icloud_cli.config import Config
9
-
10
-
11
- class TestAuthManager:
12
- """Tests for AuthManager."""
13
-
14
- def test_get_status_not_logged_in(self, tmp_path):
15
- """Status shows not logged in when no credentials stored."""
16
- config = Config(
17
- apple_id="",
18
- session_dir=str(tmp_path / "session"),
19
- config_file=tmp_path / "config.toml",
20
- )
21
- auth = AuthManager(config)
22
- status = auth.get_status()
23
-
24
- assert status["apple_id"] == "(not set)"
25
- assert status["password_stored"] == "No"
26
- assert status["session_cached"] == "No"
27
-
28
- @patch("icloud_cli.auth.keyring")
29
- def test_get_status_logged_in(self, mock_keyring, tmp_path):
30
- """Status shows logged in when credentials are stored."""
31
- config = Config(
32
- apple_id="test@icloud.com",
33
- session_dir=str(tmp_path / "session"),
34
- config_file=tmp_path / "config.toml",
35
- )
36
- # Create a session file
37
- session_dir = tmp_path / "session"
38
- session_dir.mkdir(parents=True)
39
- (session_dir / "session_cookie").write_text("fake_cookie")
40
-
41
- mock_keyring.get_password.return_value = "stored_password"
42
-
43
- auth = AuthManager(config)
44
- status = auth.get_status()
45
-
46
- assert status["apple_id"] == "test@icloud.com"
47
- assert status["password_stored"] == "Yes"
48
- assert status["session_cached"] == "Yes"
49
-
50
- @patch("icloud_cli.auth.keyring")
51
- def test_logout_clears_session(self, mock_keyring, tmp_path):
52
- """Logout clears session files."""
53
- config = Config(
54
- apple_id="test@icloud.com",
55
- session_dir=str(tmp_path / "session"),
56
- config_file=tmp_path / "config.toml",
57
- )
58
- session_dir = tmp_path / "session"
59
- session_dir.mkdir(parents=True)
60
- (session_dir / "cookie1").write_text("data")
61
- (session_dir / "cookie2").write_text("data")
62
-
63
- mock_keyring.delete_password.return_value = None
64
-
65
- auth = AuthManager(config)
66
- auth.logout()
67
-
68
- # Session files should be cleared
69
- assert list(session_dir.iterdir()) == []
70
-
71
- @patch("icloud_cli.auth.keyring")
72
- def test_has_no_cached_session(self, mock_keyring, tmp_path):
73
- """Reports no session when session dir is empty."""
74
- config = Config(
75
- apple_id="test@icloud.com",
76
- session_dir=str(tmp_path / "session"),
77
- config_file=tmp_path / "config.toml",
78
- )
79
- (tmp_path / "session").mkdir(parents=True)
80
-
81
- mock_keyring.get_password.return_value = None
82
-
83
- auth = AuthManager(config)
84
- assert not auth._has_cached_session()
85
-
86
- @patch("icloud_cli.auth.keyring")
87
- def test_imap_credentials_not_set(self, mock_keyring, tmp_path):
88
- """Returns None when IMAP credentials are not configured."""
89
- config = Config(
90
- apple_id="test@icloud.com",
91
- session_dir=str(tmp_path / "session"),
92
- config_file=tmp_path / "config.toml",
93
- )
94
- mock_keyring.get_password.return_value = None
95
-
96
- auth = AuthManager(config)
97
- assert auth.get_imap_credentials() is None