Skip to content

Commit 46c7950

Browse files
Add comprehensive unit test project with 173 tests (#32)
* Add comprehensive unit test project with 173 tests (#31) Adds MSTest test project using Rocks source-generator mocking library covering validation logic, service wiring, and all 20 MCP tool behaviors including error paths, happy paths, and multi-account scenarios. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Document required gmail.modify and Mail.ReadWrite permissions Add missing permission/scope entries to setup guides for email management operations (move, delete, mark read/unread). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c968c7b commit 46c7950

32 files changed

+3033
-4
lines changed

calendar-mcp.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
<Project Path="src/CalendarMcp.Core/CalendarMcp.Core.csproj" />
55
<Project Path="src/CalendarMcp.StdioServer/CalendarMcp.StdioServer.csproj" />
66
<Project Path="src/CalendarMcp.HttpServer/CalendarMcp.HttpServer.csproj" />
7+
<Project Path="src/CalendarMcp.Tests/CalendarMcp.Tests.csproj" />
78
</Solution>

docs/GOOGLE-SETUP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ You need to enable both Gmail and Google Calendar APIs.
128128
- `https://www.googleapis.com/auth/gmail.readonly` - Read email
129129
- `https://www.googleapis.com/auth/gmail.send` - Send email
130130
- `https://www.googleapis.com/auth/gmail.compose` - Compose email
131+
- `https://www.googleapis.com/auth/gmail.modify` - Modify emails (move, delete, mark read/unread)
131132
- `https://www.googleapis.com/auth/calendar.readonly` - Read calendar
132133
- `https://www.googleapis.com/auth/calendar.events` - Manage calendar events
133134

docs/M365-SETUP.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ After creation, note these values (you'll need them for the CLI):
7777
4. Select **Delegated permissions**
7878
5. Add these permissions:
7979
- `Mail.Read` - Read user mail
80+
- `Mail.ReadWrite` - Move, delete, and manage user mail
8081
- `Mail.Send` - Send mail as user
8182
- `Calendars.ReadWrite` - Full access to user calendars
8283
- `Files.Read` *(optional)* - Read files from OneDrive/SharePoint (only needed if using JSON calendar accounts that reference M365-hosted files)
@@ -384,6 +385,7 @@ Users will need the **Client ID** to configure their installation.
384385
| Permission | Justification |
385386
|------------|---------------|
386387
| `Mail.Read` | Read user's emails for AI summarization and search |
388+
| `Mail.ReadWrite` | Move, delete, and manage user's emails |
387389
| `Mail.Send` | Send emails on behalf of user |
388390
| `Calendars.ReadWrite` | Read user's calendar and manage events |
389391

@@ -515,6 +517,7 @@ Only grant permissions needed for current functionality:
515517

516518
**Current phase (Phase 1):**
517519
- `Mail.Read` - ✅ Required
520+
- `Mail.ReadWrite` - ✅ Required (move, delete, mark read/unread)
518521
- `Calendars.ReadWrite` - ✅ Required for read-only calendar (ReadWrite needed for MCP standard)
519522
- `Mail.Send` - ⚠️ Optional (future feature, can be granted later)
520523

@@ -668,10 +671,11 @@ Remove-AzureADServiceAppRoleAssignment -ObjectId $userId -AppRoleAssignmentId $s
668671
To use different scopes, modify the code in `M365AuthenticationService.cs`:
669672

670673
```csharp
671-
var scopes = new[]
672-
{
673-
"Mail.Read",
674-
"Mail.Send",
674+
var scopes = new[]
675+
{
676+
"Mail.Read",
677+
"Mail.ReadWrite",
678+
"Mail.Send",
675679
"Calendars.ReadWrite",
676680
"Contacts.Read" // Add additional scopes as needed
677681
};
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
using CalendarMcp.Auth;
2+
using CalendarMcp.Tests.Helpers;
3+
4+
namespace CalendarMcp.Tests.Auth;
5+
6+
[TestClass]
7+
public class AccountValidationTests
8+
{
9+
// ── ValidateAccountId ────────────────────────────────────────────
10+
11+
[TestMethod]
12+
[DataRow(null)]
13+
[DataRow("")]
14+
[DataRow(" ")]
15+
public void ValidateAccountId_NullEmptyWhitespace_ReturnsFalse(string? id)
16+
{
17+
var (isValid, error) = AccountValidation.ValidateAccountId(id);
18+
Assert.IsFalse(isValid);
19+
Assert.IsNotNull(error);
20+
}
21+
22+
[TestMethod]
23+
[DataRow("my-account")]
24+
[DataRow("account1")]
25+
[DataRow("test_account")]
26+
[DataRow("a")]
27+
[DataRow("0test")]
28+
public void ValidateAccountId_ValidSlugs_ReturnsTrue(string id)
29+
{
30+
var (isValid, _) = AccountValidation.ValidateAccountId(id);
31+
Assert.IsTrue(isValid);
32+
}
33+
34+
[TestMethod]
35+
[DataRow("MyAccount")]
36+
[DataRow("has spaces")]
37+
[DataRow("-starts-with-hyphen")]
38+
[DataRow("_starts-with-underscore")]
39+
[DataRow("UPPERCASE")]
40+
[DataRow("special!chars")]
41+
public void ValidateAccountId_InvalidSlugs_ReturnsFalse(string id)
42+
{
43+
var (isValid, _) = AccountValidation.ValidateAccountId(id);
44+
Assert.IsFalse(isValid);
45+
}
46+
47+
// ── ValidateProvider ─────────────────────────────────────────────
48+
49+
[TestMethod]
50+
[DataRow("microsoft365")]
51+
[DataRow("outlook.com")]
52+
[DataRow("google")]
53+
[DataRow("ics")]
54+
[DataRow("json")]
55+
public void ValidateProvider_KnownProviders_ReturnsTrue(string provider)
56+
{
57+
var (isValid, _) = AccountValidation.ValidateProvider(provider);
58+
Assert.IsTrue(isValid);
59+
}
60+
61+
[TestMethod]
62+
public void ValidateProvider_CaseInsensitive_ReturnsTrue()
63+
{
64+
var (isValid, _) = AccountValidation.ValidateProvider("Microsoft365");
65+
Assert.IsTrue(isValid);
66+
}
67+
68+
[TestMethod]
69+
[DataRow("unknown")]
70+
[DataRow("yahoo")]
71+
public void ValidateProvider_UnknownProvider_ReturnsFalse(string provider)
72+
{
73+
var (isValid, error) = AccountValidation.ValidateProvider(provider);
74+
Assert.IsFalse(isValid);
75+
Assert.IsNotNull(error);
76+
}
77+
78+
[TestMethod]
79+
[DataRow(null)]
80+
[DataRow("")]
81+
[DataRow(" ")]
82+
public void ValidateProvider_NullEmptyWhitespace_ReturnsFalse(string? provider)
83+
{
84+
var (isValid, _) = AccountValidation.ValidateProvider(provider);
85+
Assert.IsFalse(isValid);
86+
}
87+
88+
// ── ValidateProviderConfig ───────────────────────────────────────
89+
90+
[TestMethod]
91+
public void ValidateProviderConfig_M365_RequiresTenantIdAndClientId()
92+
{
93+
var config = new Dictionary<string, string>
94+
{
95+
["TenantId"] = "tenant-123",
96+
["ClientId"] = "client-456"
97+
};
98+
var (isValid, _) = AccountValidation.ValidateProviderConfig("microsoft365", config);
99+
Assert.IsTrue(isValid);
100+
}
101+
102+
[TestMethod]
103+
public void ValidateProviderConfig_M365_MissingTenantId_Fails()
104+
{
105+
var config = new Dictionary<string, string> { ["ClientId"] = "client-456" };
106+
var (isValid, error) = AccountValidation.ValidateProviderConfig("microsoft365", config);
107+
Assert.IsFalse(isValid);
108+
Assert.IsTrue(error!.Contains("TenantId"));
109+
}
110+
111+
[TestMethod]
112+
public void ValidateProviderConfig_OutlookCom_RequiresTenantIdAndClientId()
113+
{
114+
var config = new Dictionary<string, string>
115+
{
116+
["TenantId"] = "tenant",
117+
["ClientId"] = "client"
118+
};
119+
var (isValid, _) = AccountValidation.ValidateProviderConfig("outlook.com", config);
120+
Assert.IsTrue(isValid);
121+
}
122+
123+
[TestMethod]
124+
public void ValidateProviderConfig_Google_RequiresClientIdAndClientSecret()
125+
{
126+
var config = new Dictionary<string, string>
127+
{
128+
["ClientId"] = "client-id",
129+
["ClientSecret"] = "client-secret"
130+
};
131+
var (isValid, _) = AccountValidation.ValidateProviderConfig("google", config);
132+
Assert.IsTrue(isValid);
133+
}
134+
135+
[TestMethod]
136+
public void ValidateProviderConfig_Google_MissingClientSecret_Fails()
137+
{
138+
var config = new Dictionary<string, string> { ["ClientId"] = "client-id" };
139+
var (isValid, error) = AccountValidation.ValidateProviderConfig("google", config);
140+
Assert.IsFalse(isValid);
141+
Assert.IsTrue(error!.Contains("ClientSecret"));
142+
}
143+
144+
[TestMethod]
145+
public void ValidateProviderConfig_Ics_RequiresValidUrl()
146+
{
147+
var config = new Dictionary<string, string>
148+
{
149+
["IcsUrl"] = "https://example.com/calendar.ics"
150+
};
151+
var (isValid, _) = AccountValidation.ValidateProviderConfig("ics", config);
152+
Assert.IsTrue(isValid);
153+
}
154+
155+
[TestMethod]
156+
public void ValidateProviderConfig_Ics_InvalidUrl_Fails()
157+
{
158+
var config = new Dictionary<string, string> { ["IcsUrl"] = "not-a-url" };
159+
var (isValid, _) = AccountValidation.ValidateProviderConfig("ics", config);
160+
Assert.IsFalse(isValid);
161+
}
162+
163+
[TestMethod]
164+
public void ValidateProviderConfig_Ics_MissingUrl_Fails()
165+
{
166+
var config = new Dictionary<string, string>();
167+
var (isValid, _) = AccountValidation.ValidateProviderConfig("ics", config);
168+
Assert.IsFalse(isValid);
169+
}
170+
171+
[TestMethod]
172+
public void ValidateProviderConfig_JsonLocal_RequiresFilePath()
173+
{
174+
var config = new Dictionary<string, string>
175+
{
176+
["source"] = "local",
177+
["filePath"] = "/path/to/file.json"
178+
};
179+
var (isValid, _) = AccountValidation.ValidateProviderConfig("json", config);
180+
Assert.IsTrue(isValid);
181+
}
182+
183+
[TestMethod]
184+
public void ValidateProviderConfig_JsonOneDrive_RequiresOneDrivePath()
185+
{
186+
var config = new Dictionary<string, string>
187+
{
188+
["source"] = "onedrive",
189+
["oneDrivePath"] = "/Documents/calendar.json"
190+
};
191+
var (isValid, _) = AccountValidation.ValidateProviderConfig("json", config);
192+
Assert.IsTrue(isValid);
193+
}
194+
195+
[TestMethod]
196+
public void ValidateProviderConfig_JsonMissingSource_Fails()
197+
{
198+
var config = new Dictionary<string, string>();
199+
var (isValid, _) = AccountValidation.ValidateProviderConfig("json", config);
200+
Assert.IsFalse(isValid);
201+
}
202+
203+
[TestMethod]
204+
public void ValidateProviderConfig_JsonInvalidSource_Fails()
205+
{
206+
var config = new Dictionary<string, string> { ["source"] = "invalid" };
207+
var (isValid, _) = AccountValidation.ValidateProviderConfig("json", config);
208+
Assert.IsFalse(isValid);
209+
}
210+
211+
[TestMethod]
212+
public void ValidateProviderConfig_NullConfig_Fails()
213+
{
214+
var (isValid, _) = AccountValidation.ValidateProviderConfig("microsoft365", null);
215+
Assert.IsFalse(isValid);
216+
}
217+
218+
[TestMethod]
219+
public void ValidateProviderConfig_CaseInsensitiveKeys()
220+
{
221+
var config = new Dictionary<string, string>
222+
{
223+
["tenantid"] = "tenant",
224+
["clientid"] = "client"
225+
};
226+
var (isValid, _) = AccountValidation.ValidateProviderConfig("microsoft365", config);
227+
Assert.IsTrue(isValid);
228+
}
229+
230+
// ── RequiresAuthentication ───────────────────────────────────────
231+
232+
[TestMethod]
233+
public void RequiresAuthentication_Ics_ReturnsFalse()
234+
{
235+
var account = TestData.CreateAccount(provider: "ics",
236+
providerConfig: new() { ["IcsUrl"] = "https://example.com/cal.ics" });
237+
Assert.IsFalse(AccountValidation.RequiresAuthentication(account));
238+
}
239+
240+
[TestMethod]
241+
public void RequiresAuthentication_JsonLocal_ReturnsFalse()
242+
{
243+
var account = TestData.CreateAccount(provider: "json",
244+
providerConfig: new() { ["source"] = "local", ["filePath"] = "/path" });
245+
Assert.IsFalse(AccountValidation.RequiresAuthentication(account));
246+
}
247+
248+
[TestMethod]
249+
public void RequiresAuthentication_JsonOneDriveWithDelegate_ReturnsFalse()
250+
{
251+
var account = TestData.CreateAccount(provider: "json",
252+
providerConfig: new()
253+
{
254+
["source"] = "onedrive",
255+
["oneDrivePath"] = "/docs/cal.json",
256+
["authAccountId"] = "my-m365"
257+
});
258+
Assert.IsFalse(AccountValidation.RequiresAuthentication(account));
259+
}
260+
261+
[TestMethod]
262+
public void RequiresAuthentication_JsonOneDriveWithoutDelegate_ReturnsTrue()
263+
{
264+
var account = TestData.CreateAccount(provider: "json",
265+
providerConfig: new()
266+
{
267+
["source"] = "onedrive",
268+
["oneDrivePath"] = "/docs/cal.json"
269+
});
270+
Assert.IsTrue(AccountValidation.RequiresAuthentication(account));
271+
}
272+
273+
[TestMethod]
274+
[DataRow("microsoft365")]
275+
[DataRow("google")]
276+
[DataRow("outlook.com")]
277+
public void RequiresAuthentication_AuthProviders_ReturnsTrue(string provider)
278+
{
279+
var account = TestData.CreateAccount(provider: provider);
280+
Assert.IsTrue(AccountValidation.RequiresAuthentication(account));
281+
}
282+
283+
// ── GetAuthDelegateAccountId ─────────────────────────────────────
284+
285+
[TestMethod]
286+
public void GetAuthDelegateAccountId_NonJson_ReturnsNull()
287+
{
288+
var account = TestData.CreateAccount(provider: "microsoft365");
289+
Assert.IsNull(AccountValidation.GetAuthDelegateAccountId(account));
290+
}
291+
292+
[TestMethod]
293+
public void GetAuthDelegateAccountId_JsonOneDriveWithAuthAccountId_ReturnsId()
294+
{
295+
var account = TestData.CreateAccount(provider: "json",
296+
providerConfig: new()
297+
{
298+
["source"] = "onedrive",
299+
["oneDrivePath"] = "/docs/cal.json",
300+
["authAccountId"] = "my-m365"
301+
});
302+
Assert.AreEqual("my-m365", AccountValidation.GetAuthDelegateAccountId(account));
303+
}
304+
305+
[TestMethod]
306+
public void GetAuthDelegateAccountId_JsonLocal_ReturnsNull()
307+
{
308+
var account = TestData.CreateAccount(provider: "json",
309+
providerConfig: new() { ["source"] = "local", ["filePath"] = "/path" });
310+
Assert.IsNull(AccountValidation.GetAuthDelegateAccountId(account));
311+
}
312+
313+
[TestMethod]
314+
public void GetAuthDelegateAccountId_JsonOneDriveNoAuthAccountId_ReturnsNull()
315+
{
316+
var account = TestData.CreateAccount(provider: "json",
317+
providerConfig: new()
318+
{
319+
["source"] = "onedrive",
320+
["oneDrivePath"] = "/docs/cal.json"
321+
});
322+
Assert.IsNull(AccountValidation.GetAuthDelegateAccountId(account));
323+
}
324+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<Nullable>enable</Nullable>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<IsPackable>false</IsPackable>
7+
<IsTestProject>true</IsTestProject>
8+
</PropertyGroup>
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
11+
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" />
12+
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" />
13+
<PackageReference Include="Rocks" Version="10.0.0" />
14+
</ItemGroup>
15+
<ItemGroup>
16+
<ProjectReference Include="../CalendarMcp.Core/CalendarMcp.Core.csproj" />
17+
<ProjectReference Include="../CalendarMcp.Auth/CalendarMcp.Auth.csproj" />
18+
</ItemGroup>
19+
</Project>

0 commit comments

Comments
 (0)