Skip to content

Commit 80f808f

Browse files
committed
Add Copilot event manager tests and deduplication fixes
- Add comprehensive unit tests for CopilotAuditEventManager, covering AccessedResources, edge cases, agent updates, and credit estimation. - Add regression tests for PK violations and deduplication logic. - Add ThrowingCopilotMetadataLoader for exception path testing. - Update CopilotAuditEventManager and SQL to deduplicate TEAMS_CHAT rows and prevent duplicate copilot_chats inserts. - Refactor test infrastructure with CopilotTestBase and project file updates. - Ensure robust handling of nulls, partial data, and batch state resets.
1 parent 961d184 commit 80f808f

File tree

9 files changed

+2028
-16
lines changed

9 files changed

+2028
-16
lines changed

src/AnalyticsEngine/Tests.UnitTests/CopilotEventManagerAccessedResourcesTests.cs

Lines changed: 499 additions & 0 deletions
Large diffs are not rendered by default.

src/AnalyticsEngine/Tests.UnitTests/CopilotEventManagerEdgeCaseTests.cs

Lines changed: 679 additions & 0 deletions
Large diffs are not rendered by default.

src/AnalyticsEngine/Tests.UnitTests/CopilotEventManagerSaveTests.cs

Lines changed: 553 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
using ActivityImporter.Engine.ActivityAPI.Copilot;
2+
using Common.Entities;
3+
using Microsoft.Extensions.Logging;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
using UnitTests.FakeLoaderClasses;
9+
using WebJob.Office365ActivityImporter.Engine;
10+
using WebJob.Office365ActivityImporter.Engine.ActivityAPI.Copilot;
11+
using WebJob.Office365ActivityImporter.Engine.Entities.Serialisation;
12+
13+
namespace Tests.UnitTests
14+
{
15+
/// <summary>
16+
/// Shared base class for Copilot test classes that need DB access and common helpers.
17+
/// </summary>
18+
public abstract class CopilotTestBase
19+
{
20+
protected ILogger _logger;
21+
protected TestsAppConfig _config;
22+
23+
public CopilotTestBase()
24+
{
25+
_logger = new LoggerFactory().CreateLogger("CopilotTests");
26+
_config = new TestsAppConfig();
27+
}
28+
29+
protected async Task ClearEvents(AnalyticsEntitiesContext db)
30+
{
31+
// Clear events for test
32+
db.CopilotEventMetadataFiles.RemoveRange(db.CopilotEventMetadataFiles);
33+
db.CopilotEventMetadataMeetings.RemoveRange(db.CopilotEventMetadataMeetings);
34+
db.CopilotChats.RemoveRange(db.CopilotChats);
35+
36+
await db.SaveChangesAsync();
37+
}
38+
39+
protected async Task ClearAccessedResources(AnalyticsEntitiesContext db)
40+
{
41+
// Clear AccessedResources data for tests
42+
if (db.Database.SqlQuery<int?>("SELECT OBJECT_ID('dbo.copilot_event_accessed_resources', 'U')").FirstOrDefault().GetValueOrDefault() != 0)
43+
{
44+
db.CopilotEventAccessedResources.RemoveRange(db.CopilotEventAccessedResources);
45+
db.CopilotAccessedResourceIds.RemoveRange(db.CopilotAccessedResourceIds);
46+
db.CopilotAccessedResourceNames.RemoveRange(db.CopilotAccessedResourceNames);
47+
48+
// Clear SiteUrls if table exists
49+
if (db.Database.SqlQuery<int?>("SELECT OBJECT_ID('dbo.copilot_event_accessed_resource_site_urls', 'U')").FirstOrDefault().GetValueOrDefault() != 0)
50+
{
51+
db.CopilotAccessedResourceSiteUrls.RemoveRange(db.CopilotAccessedResourceSiteUrls);
52+
}
53+
54+
db.CopilotAccessedResourceTypes.RemoveRange(db.CopilotAccessedResourceTypes);
55+
db.SensitivityLabels.RemoveRange(db.SensitivityLabels);
56+
await db.SaveChangesAsync();
57+
}
58+
}
59+
60+
// Shared flow for saving Copilot events (normal + no permissions adaptor)
61+
// Returns list of CommonAuditEvent objects for created chat events (for further assertions if needed).
62+
protected async Task<List<CommonAuditEvent>> ExecuteCopilotEventManagerSaveFlow(
63+
ICopilotMetadataLoader adaptor,
64+
AnalyticsEntitiesContext db,
65+
Tuple<string, string> chatAgentIdAndName = null)
66+
{
67+
var allCreatedChatCommonEvents = new List<CommonAuditEvent>();
68+
var copilotEventManager = new CopilotAuditEventManager(_config.ConnectionStrings.DatabaseConnectionString, adaptor, _logger);
69+
70+
// Copilot events are: CommonAuditEvent + child CopilotAuditLogContent + copilot event data
71+
var commonEventDocEdit = new CommonAuditEvent
72+
{
73+
TimeStamp = DateTime.Now,
74+
Operation = new EventOperation { Name = "Document Edit" + DateTime.Now.Ticks },
75+
User = new User { AzureAdId = "test", UserPrincipalName = "test doc user " + DateTime.Now.Ticks },
76+
Id = Guid.NewGuid()
77+
};
78+
var commonEventMeeting = new CommonAuditEvent
79+
{
80+
TimeStamp = DateTime.Now,
81+
Operation = new EventOperation { Name = "Meeting Op" + DateTime.Now.Ticks },
82+
User = new User { AzureAdId = "test", UserPrincipalName = "test meeting user " + DateTime.Now.Ticks },
83+
Id = Guid.NewGuid()
84+
};
85+
var commonOutlook = new CommonAuditEvent
86+
{
87+
TimeStamp = DateTime.Now,
88+
Operation = new EventOperation { Name = "Outlook Op" + DateTime.Now.Ticks },
89+
User = new User { AzureAdId = "test", UserPrincipalName = "test outlook user " + DateTime.Now.Ticks },
90+
Id = Guid.NewGuid()
91+
};
92+
var commonEventChat = new CommonAuditEvent
93+
{
94+
TimeStamp = DateTime.Now,
95+
Operation = new EventOperation { Name = "Chat or something" + DateTime.Now.Ticks },
96+
User = new User { AzureAdId = "test", UserPrincipalName = "test chat user " + DateTime.Now.Ticks },
97+
Id = Guid.NewGuid()
98+
};
99+
100+
// Persist common events for FK usage
101+
allCreatedChatCommonEvents.Add(commonEventMeeting);
102+
allCreatedChatCommonEvents.Add(commonEventDocEdit);
103+
allCreatedChatCommonEvents.Add(commonOutlook);
104+
allCreatedChatCommonEvents.Add(commonEventChat);
105+
106+
db.AuditEventsCommon.AddRange(allCreatedChatCommonEvents);
107+
await db.SaveChangesAsync();
108+
109+
// Save Copilot events - one for each type we know about
110+
await copilotEventManager.SaveSingleCopilotEventToSqlStaging(new CopilotAuditLogContent
111+
{
112+
CopilotEventData = new CopilotEventData
113+
{
114+
// Teams meeting event
115+
AppHost = "test",
116+
Contexts = new List<Context>
117+
{
118+
new Context
119+
{
120+
Id = "https://microsoft.teams.com/threads/19:meeting_NDQ4MGRhYjgtMzc5MS00ZWMxLWJiZjEtOTIxZmM5Mzg3ZGFi@thread.v2", // Needs to be real
121+
Type = ActivityImportConstants.COPILOT_CONTEXT_TYPE_TEAMS_MEETING
122+
}
123+
}
124+
},
125+
AgentId = chatAgentIdAndName?.Item1,
126+
AgentName = chatAgentIdAndName?.Item2
127+
}, commonEventMeeting);
128+
129+
await copilotEventManager.SaveSingleCopilotEventToSqlStaging(new CopilotAuditLogContent
130+
{
131+
CopilotEventData = new CopilotEventData
132+
{
133+
// Document event
134+
AppHost = "Word",
135+
Contexts = new List<Context>
136+
{
137+
new Context
138+
{
139+
Id = _config.TestCopilotDocContextIdSpSite,
140+
Type = _config.TeamSiteFileExtension
141+
}
142+
}
143+
},
144+
AgentId = chatAgentIdAndName?.Item1,
145+
AgentName = chatAgentIdAndName?.Item2
146+
}, commonEventDocEdit);
147+
148+
await copilotEventManager.SaveSingleCopilotEventToSqlStaging(new CopilotAuditLogContent
149+
{
150+
CopilotEventData = new CopilotEventData
151+
{
152+
// Outlook event
153+
AppHost = "Outlook",
154+
AccessedResources = new List<AccessedResource>
155+
{
156+
new AccessedResource{ Type = "http://schema.skype.com/HyperLink" }
157+
},
158+
},
159+
AgentId = chatAgentIdAndName?.Item1,
160+
AgentName = chatAgentIdAndName?.Item2
161+
}, commonOutlook);
162+
163+
await copilotEventManager.SaveSingleCopilotEventToSqlStaging(new CopilotAuditLogContent
164+
{
165+
CopilotEventData = new CopilotEventData
166+
{
167+
// Chat event
168+
AppHost = "Teams",
169+
Contexts = new List<Context>
170+
{
171+
new Context
172+
{
173+
Id = "https://microsoft.teams.com/threads/19:somechatthread@thread.v2",
174+
Type = ActivityImportConstants.COPILOT_CONTEXT_TYPE_TEAMS_CHAT
175+
}
176+
}
177+
},
178+
AgentId = chatAgentIdAndName?.Item1,
179+
AgentName = chatAgentIdAndName?.Item2
180+
}, commonEventChat);
181+
182+
await copilotEventManager.CommitAllChanges();
183+
184+
return allCreatedChatCommonEvents;
185+
}
186+
}
187+
}

src/AnalyticsEngine/Tests.UnitTests/CopilotTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,6 +1904,69 @@ public void CopilotCreditEstimation_EmptyEvents_BothAgentTypesReturnZero()
19041904
Assert.AreEqual(0, emptyCost.TotalCredits, "Empty string should have 0 credits");
19051905
}
19061906

1907+
/// <summary>
1908+
/// Tests that an event with multiple TEAMS_CHAT contexts only produces one copilot_chats row,
1909+
/// preventing the PK violation that previously occurred when duplicate event_id rows were staged.
1910+
/// Regression test for: BatchSaveException "Violation of PRIMARY KEY constraint 'PK_dbo.copilot_chats'"
1911+
/// </summary>
1912+
[TestMethod]
1913+
public async Task CopilotEventManagerMultipleChatContextsDoesNotDuplicate()
1914+
{
1915+
using (var db = new AnalyticsEntitiesContext())
1916+
{
1917+
await ClearEvents(db);
1918+
1919+
var copilotEventManager = new CopilotAuditEventManager(_config.ConnectionStrings.DatabaseConnectionString, new FakeCopilotMetadataLoader(), _logger);
1920+
1921+
var commonEvent = new CommonAuditEvent
1922+
{
1923+
TimeStamp = DateTime.Now,
1924+
Operation = new EventOperation { Name = "MultiChatCtx Test" + DateTime.Now.Ticks },
1925+
User = new User { AzureAdId = "test", UserPrincipalName = "test@multichat.com" + DateTime.Now.Ticks },
1926+
Id = Guid.NewGuid()
1927+
};
1928+
1929+
db.AuditEventsCommon.Add(commonEvent);
1930+
await db.SaveChangesAsync();
1931+
1932+
// Stage an event that has multiple TEAMS_CHAT contexts — previously this
1933+
// added the same event_id to the staging table once per context, causing a
1934+
// PK violation on copilot_chats during the merge.
1935+
await copilotEventManager.SaveSingleCopilotEventToSqlStaging(new CopilotAuditLogContent
1936+
{
1937+
CopilotEventData = new CopilotEventData
1938+
{
1939+
AppHost = "Teams",
1940+
Contexts = new List<Context>
1941+
{
1942+
new Context
1943+
{
1944+
Id = "https://microsoft.teams.com/threads/19:chat1@thread.v2",
1945+
Type = ActivityImportConstants.COPILOT_CONTEXT_TYPE_TEAMS_CHAT
1946+
},
1947+
new Context
1948+
{
1949+
Id = "https://microsoft.teams.com/threads/19:chat2@thread.v2",
1950+
Type = ActivityImportConstants.COPILOT_CONTEXT_TYPE_TEAMS_CHAT
1951+
},
1952+
new Context
1953+
{
1954+
Id = "https://microsoft.teams.com/threads/19:chat3@thread.v2",
1955+
Type = ActivityImportConstants.COPILOT_CONTEXT_TYPE_TEAMS_CHAT
1956+
}
1957+
}
1958+
}
1959+
}, commonEvent);
1960+
1961+
// This previously threw BatchSaveException with PK violation on copilot_chats
1962+
await copilotEventManager.CommitAllChanges();
1963+
1964+
// Verify exactly one copilot_chats row was created for the event
1965+
var chatCount = await db.CopilotChats.CountAsync(c => c.AuditEvent.Id == commonEvent.Id);
1966+
Assert.AreEqual(1, chatCount, "Multiple TEAMS_CHAT contexts for the same event should produce exactly one copilot_chats row.");
1967+
}
1968+
}
1969+
19071970
/// <summary>
19081971
/// Tests that when there's no agent information (AgentName is null/empty), Cost is set to NoCost (0 credits).
19091972
/// This verifies the logic in CopilotAuditLogContent.FromJson() that assigns NoCost when AgentName is empty.

src/AnalyticsEngine/Tests.UnitTests/FakeLoaderClasses/FakeCopilotEventAdaptor.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,23 @@ public Task<string> GetUserIdFromUpn(string userPrincipalName)
5151
return Task.FromResult("testId");
5252
}
5353
}
54+
55+
/// <summary>
56+
/// Metadata loader that throws on every call — used to verify exception-handling paths.
57+
/// </summary>
58+
public class ThrowingCopilotMetadataLoader : ICopilotMetadataLoader
59+
{
60+
public Task<MeetingMetadata> GetMeetingInfo(string meetingId, string userGuid)
61+
{
62+
throw new InvalidOperationException("Simulated meeting info failure");
63+
}
64+
public Task<SpoDocumentFileInfo> GetSpoFileInfo(string copilotId, string eventUpn)
65+
{
66+
throw new InvalidOperationException("Simulated file info failure");
67+
}
68+
public Task<string> GetUserIdFromUpn(string userPrincipalName)
69+
{
70+
throw new InvalidOperationException("Simulated user lookup failure");
71+
}
72+
}
5473
}

src/AnalyticsEngine/Tests.UnitTests/Tests.UnitTests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
<ItemGroup>
3232
<Compile Remove="Generated\**" />
3333
<Compile Include="AppInsightsAuthTests.cs" />
34+
<Compile Include="CopilotEventManagerAccessedResourcesTests.cs" />
35+
<Compile Include="CopilotEventManagerEdgeCaseTests.cs" />
36+
<Compile Include="CopilotEventManagerSaveTests.cs" />
37+
<Compile Include="CopilotTestBase.cs" />
3438
<Compile Include="UserMetadataUpdaterBasicTests.cs" />
3539
<Compile Include="UserMetadataUpdaterInsertTests.cs" />
3640
<Compile Include="UserMetadataUpdaterLicenseTests.cs" />

src/AnalyticsEngine/WebJob.Office365ActivityImporter.Engine/ActivityAPI/Copilot/CopilotAuditEventManager.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ public async Task SaveSingleCopilotEventToSqlStaging(CopilotAuditLogContent audi
7777
}
7878
else if (context.Type == ActivityImportConstants.COPILOT_CONTEXT_TYPE_TEAMS_CHAT)
7979
{
80-
AddChatOnly(auditRecord, baseOfficeEvent);
81-
eventChats++; _totalChatOnlyCount++;
82-
// continue; chat-only does not block other context types
80+
if (eventChats == 0) // safeguard against multiple chat contexts for the same event
81+
{
82+
AddChatOnly(auditRecord, baseOfficeEvent);
83+
eventChats++; _totalChatOnlyCount++;
84+
}
8385
}
8486
else
8587
{

src/AnalyticsEngine/WebJob.Office365ActivityImporter.Engine/ActivityAPI/Copilot/SQL/common_upsert_copilot_agents.sql

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,27 @@ WHERE EXISTS (
3535

3636

3737
-- Insert chat where there is no existing copilot_chats record for the event_id
38+
-- Uses ROW_NUMBER to deduplicate staging table rows with the same event_id
3839
INSERT INTO dbo.copilot_chats (event_id, app_host, agent_id, copilot_credit_estimate_total, copilot_credit_estimate_json)
39-
SELECT
40-
i.event_id,
41-
i.app_host,
42-
ca.id,
43-
i.copilot_credit_estimate_total,
44-
i.copilot_credit_estimate_json
45-
FROM dbo.[${STAGING_TABLE_ACTIVITY}] AS i
46-
LEFT JOIN dbo.copilot_agents AS ca
47-
ON ca.agent_id = i.agent_id
48-
WHERE NOT EXISTS (
49-
SELECT 1
50-
FROM dbo.copilot_chats AS ec
51-
WHERE ec.event_id = i.event_id
40+
SELECT event_id, app_host, agent_id, copilot_credit_estimate_total, copilot_credit_estimate_json
41+
FROM (
42+
SELECT
43+
i.event_id,
44+
i.app_host,
45+
ca.id AS agent_id,
46+
i.copilot_credit_estimate_total,
47+
i.copilot_credit_estimate_json,
48+
ROW_NUMBER() OVER (PARTITION BY i.event_id ORDER BY (SELECT NULL)) AS rn
49+
FROM dbo.[${STAGING_TABLE_ACTIVITY}] AS i
50+
LEFT JOIN dbo.copilot_agents AS ca
51+
ON ca.agent_id = i.agent_id
52+
WHERE NOT EXISTS (
53+
SELECT 1
54+
FROM dbo.copilot_chats AS ec
55+
WHERE ec.event_id = i.event_id
5256
)
57+
) AS deduped
58+
WHERE rn = 1
5359

5460

5561
-- Update existing chat records with Copilot Credit estimation data if not already present

0 commit comments

Comments
 (0)