Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Morpheus.Tests/UsersServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,29 @@ public async Task TryGetCreateUser_CreatesUserFromNonSocketUser()
Assert.Single(await db.Users.ToListAsync());
}

[Fact]
public async Task TryGetCreateUserAsync_WhenAnotherHandlerCreatesUser_ReturnsPersistedUser()
{
await using SqliteConnection connection = new("Data Source=:memory:");
await connection.OpenAsync();

DbContextOptions<DB> options = new DbContextOptionsBuilder<DB>()
.UseSqlite(connection)
.Options;
await using (DB setup = new(options))
await setup.Database.EnsureCreatedAsync();

await using RacingDb db = new(options);
db.InsertCompetingUserOnNextSave = true;
UsersService service = new(db, new LogsService(new LogQueue()));

User result = await service.TryGetCreateUserAsync(123, "first");

Assert.Equal((ulong)123, result.DiscordId);
Assert.Equal("concurrent", result.Username);
Assert.Equal(1, await db.Users.CountAsync());
}

private static IUser CreateUser(ulong id, string username)
{
IUser user = DispatchProxy.Create<IUser, UserProxy>();
Expand All @@ -53,4 +76,31 @@ public class UserProxy : DispatchProxy
_ => throw new NotSupportedException(targetMethod?.Name)
};
}

private sealed class RacingDb : DB
{
public RacingDb(DbContextOptions<DB> options) : base(options)
{
}

public bool InsertCompetingUserOnNextSave { get; set; }

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
if (InsertCompetingUserOnNextSave)
{
InsertCompetingUserOnNextSave = false;
ChangeTracker.Clear();
Users.Add(new User
{
DiscordId = 123,
Username = "concurrent"
});
await base.SaveChangesAsync(cancellationToken);
throw new DbUpdateException("Simulated concurrent unique-key conflict.");
}

return await base.SaveChangesAsync(cancellationToken);
}
}
}
29 changes: 23 additions & 6 deletions Services/UsersService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,42 @@
namespace Morpheus.Services;
public class UsersService(DB dbContext, LogsService logsService)
{
public async Task<User> TryGetCreateUser(IUser user)
public Task<User> TryGetCreateUser(IUser user) =>
TryGetCreateUserAsync(user.Id, user.Username);

internal async Task<User> TryGetCreateUserAsync(ulong discordId, string username)
{
User? userDb = await dbContext.Users.FirstOrDefaultAsync(u => u.DiscordId == user.Id);
User? userDb = await dbContext.Users.FirstOrDefaultAsync(u => u.DiscordId == discordId);

if (userDb != null)
return userDb;

userDb = new User()
{
DiscordId = user.Id,
Username = user.Username,
DiscordId = discordId,
Username = username,
InsertDate = DateTime.UtcNow,
LastUsernameCheck = DateTime.UtcNow
};

await dbContext.Users.AddAsync(userDb);
await dbContext.SaveChangesAsync();
try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Another message handler may have created the same Discord user after our initial
// lookup. Re-query instead of failing activity processing on the unique index.
dbContext.ChangeTracker.Clear();
User? concurrentUser = await dbContext.Users.FirstOrDefaultAsync(u => u.DiscordId == discordId);
if (concurrentUser != null)
return concurrentUser;

throw;
}

logsService.Log($"New user created {user.Username}", Discord.LogSeverity.Verbose);
logsService.Log($"New user created {username}", Discord.LogSeverity.Verbose);

return userDb;
}
Expand Down