| 1 | using AgentNotes.Core; |
| 2 | using System.Text; |
| 3 | |
| 4 | namespace AgentNotesMcp.Tests; |
| 5 | |
| 6 | public sealed class MultiRootKnowledgeTests |
| 7 | { |
| 8 | [Fact] |
| 9 | public void ReadKnowledgeFile_UsesReadOnlyRoot_ByKnowledgeRootId() |
| 10 | { |
| 11 | using var primary = LocalSettingsLoaderTests.TempKnowledgeRoot.Create(); |
| 12 | using var group = CreateGroupKbRoot(); |
| 13 | using var runtime = InstallWithReadOnly(primary.Path, group.Path); |
| 14 | var storage = new NotesStorage(); |
| 15 | |
| 16 | var text = storage.ReadKnowledgeFile(null, "group/smoke-test-v1.md", knowledgeRootId: "group"); |
| 17 | Assert.Contains("group-kb smoke", text, StringComparison.Ordinal); |
| 18 | } |
| 19 | |
| 20 | [Fact] |
| 21 | public void WriteKnowledgeFile_ToReadOnlyRoot_Throws() |
| 22 | { |
| 23 | using var primary = LocalSettingsLoaderTests.TempKnowledgeRoot.Create(); |
| 24 | using var group = CreateGroupKbRoot(); |
| 25 | using var runtime = InstallWithReadOnly(primary.Path, group.Path); |
| 26 | var storage = new NotesStorage(); |
| 27 | |
| 28 | var ex = Assert.Throws<InvalidOperationException>(() => |
| 29 | storage.WriteKnowledgeFile(null, "group/evil.md", "nope", knowledgeRootId: "group")); |
| 30 | Assert.Contains("read-only", ex.Message, StringComparison.OrdinalIgnoreCase); |
| 31 | } |
| 32 | |
| 33 | [Fact] |
| 34 | public void WriteKnowledgeFile_ToReadOnlyPath_Throws() |
| 35 | { |
| 36 | using var primary = LocalSettingsLoaderTests.TempKnowledgeRoot.Create(); |
| 37 | using var group = CreateGroupKbRoot(); |
| 38 | using var runtime = InstallWithReadOnly(primary.Path, group.Path); |
| 39 | var storage = new NotesStorage(); |
| 40 | |
| 41 | var ex = Assert.Throws<InvalidOperationException>(() => |
| 42 | storage.WriteKnowledgeFile(group.Path, "group/evil.md", "nope")); |
| 43 | Assert.Contains("read-only", ex.Message, StringComparison.OrdinalIgnoreCase); |
| 44 | } |
| 45 | |
| 46 | [Fact] |
| 47 | public void KnowledgePath_And_KnowledgeRootId_AreMutuallyExclusive() |
| 48 | { |
| 49 | using var primary = LocalSettingsLoaderTests.TempKnowledgeRoot.Create(); |
| 50 | using var group = CreateGroupKbRoot(); |
| 51 | using var runtime = InstallWithReadOnly(primary.Path, group.Path); |
| 52 | |
| 53 | var ex = Assert.Throws<ArgumentException>(() => |
| 54 | NotesStorage.ResolveKnowledgeRoot(primary.Path, "group")); |
| 55 | Assert.Contains("not both", ex.Message, StringComparison.OrdinalIgnoreCase); |
| 56 | } |
| 57 | |
| 58 | internal static AgentNotesTestToml.RuntimeScope InstallWithReadOnlyPublic(string primaryPath, string groupPath) => |
| 59 | InstallWithReadOnly(primaryPath, groupPath); |
| 60 | |
| 61 | internal static GroupKbRoot CreateGroupKbRootPublic() => CreateGroupKbRoot(); |
| 62 | |
| 63 | private static AgentNotesTestToml.RuntimeScope InstallWithReadOnly( |
| 64 | string primaryPath, |
| 65 | string groupPath) |
| 66 | { |
| 67 | var toml = $""" |
| 68 | version = 1 |
| 69 | |
| 70 | [knowledge] |
| 71 | primary = "test" |
| 72 | |
| 73 | [knowledge.roots] |
| 74 | test = "{primaryPath.Replace('\\', '/')}" |
| 75 | |
| 76 | [[knowledge.read_only]] |
| 77 | id = "group" |
| 78 | path = "{groupPath.Replace('\\', '/')}" |
| 79 | |
| 80 | [workspace] |
| 81 | default_scope = "door-to-singularity" |
| 82 | scope_map = "work/local/workspace-scope-map-v1.md" |
| 83 | scope_aliases = "work/local/scope-alias-map-v1.md" |
| 84 | |
| 85 | [status] |
| 86 | enabled = false |
| 87 | port = 17341 |
| 88 | bind = "127.0.0.1" |
| 89 | """; |
| 90 | var path = Path.Combine(Path.GetTempPath(), "AgentNotesMcpTests", $"cfg-{Guid.NewGuid():N}.toml"); |
| 91 | Directory.CreateDirectory(Path.GetDirectoryName(path)!); |
| 92 | File.WriteAllText(path, toml, Encoding.UTF8); |
| 93 | return AgentNotesTestToml.Install(path); |
| 94 | } |
| 95 | |
| 96 | private static GroupKbRoot CreateGroupKbRoot() |
| 97 | { |
| 98 | var path = Path.Combine(Path.GetTempPath(), "AgentNotesMcpTests", "group-kb", Guid.NewGuid().ToString("N")); |
| 99 | Directory.CreateDirectory(Path.Combine(path, "knowledge", "group")); |
| 100 | File.WriteAllText( |
| 101 | Path.Combine(path, "knowledge", "group", "smoke-test-v1.md"), |
| 102 | "# Group KB smoke\n\ngroup-kb smoke test content.\n", |
| 103 | Encoding.UTF8); |
| 104 | File.WriteAllText(Path.Combine(path, "agent-notes.md"), "# Group hot (stub)\n", Encoding.UTF8); |
| 105 | return new GroupKbRoot(path); |
| 106 | } |
| 107 | |
| 108 | internal sealed class GroupKbRoot(string path) : IDisposable |
| 109 | { |
| 110 | internal string Path { get; } = path; |
| 111 | |
| 112 | public void Dispose() |
| 113 | { |
| 114 | try |
| 115 | { |
| 116 | if (Directory.Exists(Path)) |
| 117 | Directory.Delete(Path, recursive: true); |
| 118 | } |
| 119 | catch (Exception ex) |
| 120 | { |
| 121 | throw new InvalidOperationException($"Failed to cleanup group-kb: {Path}", ex); |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |