Coverage for src/prosemark/adapters/fake_node_repo.py: 100%
74 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1# Copyright (c) 2024 Prosemark Contributors
2# This software is licensed under the MIT License
4"""In-memory fake implementation of NodeRepo for testing."""
6from prosemark.domain.models import NodeId
7from prosemark.exceptions import NodeIdentityError, NodeNotFoundError
8from prosemark.ports.node_repo import NodeRepo
11class FakeNodeRepo(NodeRepo):
12 """In-memory fake implementation of NodeRepo for testing.
14 Provides complete node file management functionality using memory storage
15 instead of filesystem operations. Maintains the same interface contract as
16 production implementations but without actual file I/O.
18 This fake stores node frontmatter and tracks file creation/deletion for
19 test assertions. It simulates all NodeRepo operations including editor
20 integration (tracked but not executed).
22 Examples:
23 >>> from prosemark.domain.models import NodeId
24 >>> repo = FakeNodeRepo()
25 >>> node_id = NodeId('0192f0c1-2345-7123-8abc-def012345678')
26 >>> repo.create(node_id, 'Test Title', 'Test synopsis')
27 >>> frontmatter = repo.read_frontmatter(node_id)
28 >>> frontmatter['title']
29 'Test Title'
31 """
33 def __init__(self) -> None:
34 """Initialize empty fake repository."""
35 self._nodes: dict[str, dict[str, str | None]] = {}
36 self._editor_calls: list[tuple[str, str]] = []
37 self._delete_calls: list[tuple[str, bool]] = []
38 self._open_in_editor_exception: Exception | None = None
39 self._existing_files: set[str] = set()
40 self._existing_notes_files: set[str] = set()
41 self._frontmatter_mismatches: dict[str, str] = {}
43 def create(self, node_id: 'NodeId', title: str | None, synopsis: str | None) -> None:
44 """Create new node files with initial frontmatter.
46 Args:
47 node_id: Unique identifier for the node
48 title: Optional title for the node
49 synopsis: Optional synopsis/summary for the node
51 Raises:
52 NodeIdentityError: If node with this ID already exists
54 """
55 node_key = str(node_id)
56 if node_key in self._nodes: # pragma: no cover
57 msg = 'Node already exists'
58 raise NodeIdentityError(msg, node_key)
60 # Store frontmatter with current timestamp placeholders
61 # In real implementation, these would come from Clock port
62 self._nodes[node_key] = {
63 'id': node_key,
64 'title': title,
65 'synopsis': synopsis,
66 'created': '2025-09-14T12:00:00Z', # Placeholder timestamp
67 'updated': '2025-09-14T12:00:00Z', # Placeholder timestamp
68 }
70 # Auto-add to existing files for audit testing
71 self._existing_files.add(node_key)
72 self._existing_notes_files.add(node_key)
74 def read_frontmatter(self, node_id: 'NodeId') -> dict[str, str | None]:
75 """Read frontmatter from node draft file.
77 Args:
78 node_id: NodeId to read frontmatter for
80 Returns:
81 Dictionary containing frontmatter fields
83 Raises:
84 NodeNotFoundError: If node file doesn't exist
86 """
87 node_key = str(node_id)
88 if node_key not in self._nodes: # pragma: no cover
89 msg = 'Node not found'
90 raise NodeNotFoundError(msg, node_key)
92 frontmatter = self._nodes[node_key].copy()
94 # Apply frontmatter mismatch if configured for testing
95 if node_key in self._frontmatter_mismatches:
96 frontmatter['id'] = self._frontmatter_mismatches[node_key]
98 return frontmatter
100 def write_frontmatter(self, node_id: 'NodeId', fm: dict[str, str | None]) -> None: # pragma: no cover
101 """Update frontmatter in node draft file.
103 Args:
104 node_id: NodeId to update frontmatter for
105 fm: Dictionary containing frontmatter fields to write
107 Raises:
108 NodeNotFoundError: If node file doesn't exist
110 """
111 node_key = str(node_id)
112 if node_key not in self._nodes: # pragma: no cover
113 msg = 'Node not found'
114 raise NodeNotFoundError(msg, node_key)
116 # Update the stored frontmatter
117 self._nodes[node_key] = fm.copy()
119 def open_in_editor(self, node_id: 'NodeId', part: str) -> None:
120 """Open specified node part in editor.
122 Args:
123 node_id: NodeId to open in editor
124 part: Which part to open ('draft', 'notes', 'synopsis')
126 Raises:
127 NodeNotFoundError: If node file doesn't exist
128 ValueError: If part is not a valid option
130 """
131 node_key = str(node_id)
132 if node_key not in self._nodes: # pragma: no cover
133 msg = 'Node not found'
134 raise NodeNotFoundError(msg, node_key)
136 if part not in {'draft', 'notes', 'synopsis'}: # pragma: no cover
137 msg = 'Invalid part specification'
138 raise ValueError(msg, part)
140 # Track editor calls for test assertions (before potential exception)
141 self._editor_calls.append((node_key, part))
143 # Check if exception should be raised (for testing)
144 if self._open_in_editor_exception is not None:
145 exception_to_raise = self._open_in_editor_exception
146 self._open_in_editor_exception = None # Reset after raising
147 raise exception_to_raise
149 def delete(self, node_id: 'NodeId', *, delete_files: bool) -> None:
150 """Remove node from system.
152 Args:
153 node_id: NodeId to delete
154 delete_files: If True, simulates file deletion
156 Raises:
157 NodeNotFoundError: If node doesn't exist
159 """
160 node_key = str(node_id)
161 if node_key not in self._nodes: # pragma: no cover
162 msg = 'Node not found'
163 raise NodeNotFoundError(msg, node_key)
165 # Track delete calls for test assertions
166 self._delete_calls.append((node_key, delete_files))
168 # Remove from memory storage
169 del self._nodes[node_key]
171 def node_exists(self, node_id: 'NodeId') -> bool:
172 """Check if node exists in repository.
174 Helper method for test assertions.
176 Args:
177 node_id: NodeId to check
179 Returns:
180 True if node exists, False otherwise
182 """
183 return str(node_id) in self._nodes
185 def get_editor_calls(self) -> list[tuple[str, str]]: # pragma: no cover
186 """Get list of editor calls for test assertions.
188 Returns:
189 List of tuples containing (node_id, part) for each editor call
191 """
192 return self._editor_calls.copy()
194 def clear_editor_calls(self) -> None: # pragma: no cover
195 """Clear editor call history.
197 Useful for resetting state between test cases.
199 """
200 self._editor_calls.clear()
202 def get_delete_calls(self) -> list[tuple[str, bool]]:
203 """Get list of delete calls for test assertions.
205 Returns:
206 List of tuples containing (node_id, delete_files) for each delete call
208 """
209 return self._delete_calls.copy()
211 def delete_called_with(self, node_id: 'NodeId', *, delete_files: bool) -> bool:
212 """Check if delete was called with specific parameters.
214 Args:
215 node_id: NodeId to check
216 delete_files: delete_files parameter to check
218 Returns:
219 True if delete was called with these parameters
221 """
222 return (str(node_id), delete_files) in self._delete_calls
224 def clear_delete_calls(self) -> None:
225 """Clear delete call history.
227 Useful for resetting state between test cases.
229 """
230 self._delete_calls.clear()
232 def get_node_count(self) -> int: # pragma: no cover
233 """Get total number of nodes in repository.
235 Returns:
236 Count of nodes currently stored
238 """
239 return len(self._nodes)
241 def set_open_in_editor_exception(self, exception: Exception | None) -> None:
242 """Set an exception to be raised on next open_in_editor call.
244 Args:
245 exception: Exception to raise, or None to clear
247 """
248 self._open_in_editor_exception = exception
250 @property
251 def open_in_editor_calls(self) -> list[tuple['NodeId', str]]:
252 """Get list of open_in_editor calls for test assertions.
254 Returns:
255 List of tuples containing (node_id, part) for each editor call
257 """
258 return [(NodeId(node_key), part) for node_key, part in self._editor_calls]
260 @open_in_editor_calls.setter
261 def open_in_editor_calls(self, value: list[tuple['NodeId', str]]) -> None:
262 """Set the open_in_editor_calls list (for test reset).
264 Args:
265 value: New list value
267 """
268 self._editor_calls = [(str(node_id), part) for node_id, part in value]
270 def set_existing_files(self, file_ids: list[str]) -> None:
271 """Set which node files exist for audit testing.
273 Args:
274 file_ids: List of node ID strings that should be considered as existing files
276 """
277 self._existing_files = set(file_ids)
279 def set_existing_notes_files(self, file_ids: list[str]) -> None:
280 """Set which node notes files exist for audit testing.
282 Args:
283 file_ids: List of node ID strings that should be considered as having existing notes files
285 """
286 self._existing_notes_files = set(file_ids)
288 def get_existing_files(self) -> set['NodeId']:
289 """Get all existing node file IDs for audit testing.
291 Returns:
292 Set of NodeIds that exist as files
294 """
295 # Filter out invalid node IDs to match real file system behavior
296 valid_node_ids = set()
297 for file_id in self._existing_files:
298 try:
299 valid_node_ids.add(NodeId(file_id))
300 except (ValueError, NodeIdentityError):
301 # Skip invalid node IDs (similar to real file system behavior)
302 continue
303 return valid_node_ids
305 def get_existing_notes_files(self) -> set[str]:
306 """Get all existing notes file IDs for audit testing.
308 Returns:
309 Set of node ID strings that exist as notes files
311 """
312 return self._existing_notes_files.copy()
314 def file_exists(self, node_id: 'NodeId', file_type: str) -> bool:
315 """Check if a specific node file exists.
317 Args:
318 node_id: NodeId to check
319 file_type: Type of file to check ('draft' for {id}.md, 'notes' for {id}.notes.md)
321 Returns:
322 True if the file exists, False otherwise
324 Raises:
325 ValueError: If file_type is not valid
327 """
328 if file_type not in {'draft', 'notes'}:
329 msg = f'Invalid file_type: {file_type}. Must be "draft" or "notes"'
330 raise ValueError(msg)
332 node_key = str(node_id)
334 if file_type == 'draft':
335 # Check if the main .md file exists (tracked in _existing_files)
336 return node_key in self._existing_files
337 # Check if the notes file exists (tracked in _existing_notes_files)
338 return node_key in self._existing_notes_files
340 def set_frontmatter_mismatch(self, file_id: str, frontmatter_id: str) -> None:
341 """Set a frontmatter ID mismatch for audit testing.
343 Args:
344 file_id: The file's actual node ID
345 frontmatter_id: The mismatched ID in the file's frontmatter
347 """
348 self._frontmatter_mismatches[file_id] = frontmatter_id