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

1# Copyright (c) 2024 Prosemark Contributors 

2# This software is licensed under the MIT License 

3 

4"""In-memory fake implementation of NodeRepo for testing.""" 

5 

6from prosemark.domain.models import NodeId 

7from prosemark.exceptions import NodeIdentityError, NodeNotFoundError 

8from prosemark.ports.node_repo import NodeRepo 

9 

10 

11class FakeNodeRepo(NodeRepo): 

12 """In-memory fake implementation of NodeRepo for testing. 

13 

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. 

17 

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). 

21 

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' 

30 

31 """ 

32 

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] = {} 

42 

43 def create(self, node_id: 'NodeId', title: str | None, synopsis: str | None) -> None: 

44 """Create new node files with initial frontmatter. 

45 

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 

50 

51 Raises: 

52 NodeIdentityError: If node with this ID already exists 

53 

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) 

59 

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 } 

69 

70 # Auto-add to existing files for audit testing 

71 self._existing_files.add(node_key) 

72 self._existing_notes_files.add(node_key) 

73 

74 def read_frontmatter(self, node_id: 'NodeId') -> dict[str, str | None]: 

75 """Read frontmatter from node draft file. 

76 

77 Args: 

78 node_id: NodeId to read frontmatter for 

79 

80 Returns: 

81 Dictionary containing frontmatter fields 

82 

83 Raises: 

84 NodeNotFoundError: If node file doesn't exist 

85 

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) 

91 

92 frontmatter = self._nodes[node_key].copy() 

93 

94 # Apply frontmatter mismatch if configured for testing 

95 if node_key in self._frontmatter_mismatches: 

96 frontmatter['id'] = self._frontmatter_mismatches[node_key] 

97 

98 return frontmatter 

99 

100 def write_frontmatter(self, node_id: 'NodeId', fm: dict[str, str | None]) -> None: # pragma: no cover 

101 """Update frontmatter in node draft file. 

102 

103 Args: 

104 node_id: NodeId to update frontmatter for 

105 fm: Dictionary containing frontmatter fields to write 

106 

107 Raises: 

108 NodeNotFoundError: If node file doesn't exist 

109 

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) 

115 

116 # Update the stored frontmatter 

117 self._nodes[node_key] = fm.copy() 

118 

119 def open_in_editor(self, node_id: 'NodeId', part: str) -> None: 

120 """Open specified node part in editor. 

121 

122 Args: 

123 node_id: NodeId to open in editor 

124 part: Which part to open ('draft', 'notes', 'synopsis') 

125 

126 Raises: 

127 NodeNotFoundError: If node file doesn't exist 

128 ValueError: If part is not a valid option 

129 

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) 

135 

136 if part not in {'draft', 'notes', 'synopsis'}: # pragma: no cover 

137 msg = 'Invalid part specification' 

138 raise ValueError(msg, part) 

139 

140 # Track editor calls for test assertions (before potential exception) 

141 self._editor_calls.append((node_key, part)) 

142 

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 

148 

149 def delete(self, node_id: 'NodeId', *, delete_files: bool) -> None: 

150 """Remove node from system. 

151 

152 Args: 

153 node_id: NodeId to delete 

154 delete_files: If True, simulates file deletion 

155 

156 Raises: 

157 NodeNotFoundError: If node doesn't exist 

158 

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) 

164 

165 # Track delete calls for test assertions 

166 self._delete_calls.append((node_key, delete_files)) 

167 

168 # Remove from memory storage 

169 del self._nodes[node_key] 

170 

171 def node_exists(self, node_id: 'NodeId') -> bool: 

172 """Check if node exists in repository. 

173 

174 Helper method for test assertions. 

175 

176 Args: 

177 node_id: NodeId to check 

178 

179 Returns: 

180 True if node exists, False otherwise 

181 

182 """ 

183 return str(node_id) in self._nodes 

184 

185 def get_editor_calls(self) -> list[tuple[str, str]]: # pragma: no cover 

186 """Get list of editor calls for test assertions. 

187 

188 Returns: 

189 List of tuples containing (node_id, part) for each editor call 

190 

191 """ 

192 return self._editor_calls.copy() 

193 

194 def clear_editor_calls(self) -> None: # pragma: no cover 

195 """Clear editor call history. 

196 

197 Useful for resetting state between test cases. 

198 

199 """ 

200 self._editor_calls.clear() 

201 

202 def get_delete_calls(self) -> list[tuple[str, bool]]: 

203 """Get list of delete calls for test assertions. 

204 

205 Returns: 

206 List of tuples containing (node_id, delete_files) for each delete call 

207 

208 """ 

209 return self._delete_calls.copy() 

210 

211 def delete_called_with(self, node_id: 'NodeId', *, delete_files: bool) -> bool: 

212 """Check if delete was called with specific parameters. 

213 

214 Args: 

215 node_id: NodeId to check 

216 delete_files: delete_files parameter to check 

217 

218 Returns: 

219 True if delete was called with these parameters 

220 

221 """ 

222 return (str(node_id), delete_files) in self._delete_calls 

223 

224 def clear_delete_calls(self) -> None: 

225 """Clear delete call history. 

226 

227 Useful for resetting state between test cases. 

228 

229 """ 

230 self._delete_calls.clear() 

231 

232 def get_node_count(self) -> int: # pragma: no cover 

233 """Get total number of nodes in repository. 

234 

235 Returns: 

236 Count of nodes currently stored 

237 

238 """ 

239 return len(self._nodes) 

240 

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. 

243 

244 Args: 

245 exception: Exception to raise, or None to clear 

246 

247 """ 

248 self._open_in_editor_exception = exception 

249 

250 @property 

251 def open_in_editor_calls(self) -> list[tuple['NodeId', str]]: 

252 """Get list of open_in_editor calls for test assertions. 

253 

254 Returns: 

255 List of tuples containing (node_id, part) for each editor call 

256 

257 """ 

258 return [(NodeId(node_key), part) for node_key, part in self._editor_calls] 

259 

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). 

263 

264 Args: 

265 value: New list value 

266 

267 """ 

268 self._editor_calls = [(str(node_id), part) for node_id, part in value] 

269 

270 def set_existing_files(self, file_ids: list[str]) -> None: 

271 """Set which node files exist for audit testing. 

272 

273 Args: 

274 file_ids: List of node ID strings that should be considered as existing files 

275 

276 """ 

277 self._existing_files = set(file_ids) 

278 

279 def set_existing_notes_files(self, file_ids: list[str]) -> None: 

280 """Set which node notes files exist for audit testing. 

281 

282 Args: 

283 file_ids: List of node ID strings that should be considered as having existing notes files 

284 

285 """ 

286 self._existing_notes_files = set(file_ids) 

287 

288 def get_existing_files(self) -> set['NodeId']: 

289 """Get all existing node file IDs for audit testing. 

290 

291 Returns: 

292 Set of NodeIds that exist as files 

293 

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 

304 

305 def get_existing_notes_files(self) -> set[str]: 

306 """Get all existing notes file IDs for audit testing. 

307 

308 Returns: 

309 Set of node ID strings that exist as notes files 

310 

311 """ 

312 return self._existing_notes_files.copy() 

313 

314 def file_exists(self, node_id: 'NodeId', file_type: str) -> bool: 

315 """Check if a specific node file exists. 

316 

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) 

320 

321 Returns: 

322 True if the file exists, False otherwise 

323 

324 Raises: 

325 ValueError: If file_type is not valid 

326 

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) 

331 

332 node_key = str(node_id) 

333 

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 

339 

340 def set_frontmatter_mismatch(self, file_id: str, frontmatter_id: str) -> None: 

341 """Set a frontmatter ID mismatch for audit testing. 

342 

343 Args: 

344 file_id: The file's actual node ID 

345 frontmatter_id: The mismatched ID in the file's frontmatter 

346 

347 """ 

348 self._frontmatter_mismatches[file_id] = frontmatter_id