diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..3188b43
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,31 @@
+name: .NET Build and Test
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Install Aspire workload
+ run: dotnet workload install aspire
+
+ - name: Restore dependencies
+ run: dotnet restore bottomly.net/bottomly.net.slnx
+
+ - name: Build
+ run: dotnet build bottomly.net/bottomly.net.slnx --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test bottomly.net/bottomly.net.slnx --no-build --configuration Release --verbosity normal
diff --git a/bottomly.net/.gitignore b/bottomly.net/.gitignore
new file mode 100644
index 0000000..d79eb76
--- /dev/null
+++ b/bottomly.net/.gitignore
@@ -0,0 +1,518 @@
+# .NET and Visual Studio .gitignore
+
+# Planning documents
+.plans/
+
+# Confidential member seed data
+MemberSeedData/
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Mono auto generated files
+mono_crash.*
+
+# Windows image file caches
+Thumbs.db
+ehthumbs.db
+
+# Folder config file
+Desktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Mac desktop service store files
+.DS_Store
+
+# JetBrains IDEs
+.idea/
+*.iml
+*.ipr
+*.iws
+
+# Temporary files
+*.tmp
+*.temp
+*.bak
+*.swp
+*.swo
+
+# Log files
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Coverage directory used by tools like istanbul
+coverage/
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+.env.production
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+public
+
+# Storybook build outputs
+.out
+.storybook-out
+
+# Rollup.js default build output
+dist/
+
+# Uncomment the following line if you want to include yarn.lock
+# yarn.lock
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
\ No newline at end of file
diff --git a/bottomly.net/.nuget/NuGet.config b/bottomly.net/.nuget/NuGet.config
new file mode 100644
index 0000000..2159df8
--- /dev/null
+++ b/bottomly.net/.nuget/NuGet.config
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.AppHost/AppHost.cs b/bottomly.net/Bottomly.AppHost/AppHost.cs
new file mode 100644
index 0000000..a60a1f3
--- /dev/null
+++ b/bottomly.net/Bottomly.AppHost/AppHost.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Configuration;
+using Projects;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+builder.Configuration.AddUserSecrets();
+
+var mongo = builder.AddMongoDB("mongo")
+ .WithDataVolume()
+ .WithLifetime(ContainerLifetime.Persistent);
+
+var mongodb = mongo.AddDatabase("mongodb");
+
+// var ollama = builder.AddOllama("ollama")
+// .WithEnvironment("OLLAMA_API_KEY", builder.Configuration["AppHost:OllamaApiKey"])
+// .WithDataVolume()
+// .WithLifetime(ContainerLifetime.Persistent);
+
+var ollama = builder.AddOllamaLocal("ollama");
+
+var bottomlyModel = ollama.AddModel("bottomlymodel", "qwen3.5:cloud");
+
+var bottomly = builder.AddProject("bottomly")
+ .WaitFor(mongodb)
+ .WaitFor(ollama)
+ .WithReference(bottomlyModel)
+ .WithReference(mongodb)
+ ;
+
+var app = builder.Build();
+
+app.Run();
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj
new file mode 100644
index 0000000..ec78659
--- /dev/null
+++ b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ 5085e312-407d-49ea-8b7c-815877981360
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs
new file mode 100644
index 0000000..ecedd3c
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs
@@ -0,0 +1,47 @@
+using Bottomly.Commands;
+using Bottomly.Configuration;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Options;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.Commands;
+
+public class GiphyCommandTests
+{
+ [Fact]
+ public async Task ExecuteAsync_EmptyInput_ReturnsNull()
+ {
+ var mockFactory = new Mock();
+ var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" });
+ var command = new GiphyCommand(mockFactory.Object, options);
+
+ var result = await command.ExecuteAsync("");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithResult_ReturnsUrl()
+ {
+ const string json = """{"data":{"url":"https://giphy.com/gifs/funny-cat"}}""";
+ var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" });
+ var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options);
+
+ var result = await command.ExecuteAsync("cat");
+
+ result.ShouldBe("https://giphy.com/gifs/funny-cat");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_EmptyDataArray_ReturnsNull()
+ {
+ const string json = """{"data":[]}""";
+ var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" });
+ var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options);
+
+ var result = await command.ExecuteAsync("obscuresearch");
+
+ result.ShouldBeNull();
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs
new file mode 100644
index 0000000..c0cc169
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs
@@ -0,0 +1,97 @@
+using Bottomly.Commands;
+using Bottomly.Tests.Helpers;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.Commands;
+
+public class RegSearchCommandTests
+{
+ [Fact]
+ public async Task ExecuteAsync_EmptyInput_ReturnsRegistrationMissingMessage()
+ {
+ var command = new RegSearchCommand(new Mock().Object);
+
+ var result = await command.ExecuteAsync(" ");
+
+ result.ShouldBe("Registration missing");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_TooLongInput_ReturnsTooLongMessage()
+ {
+ var command = new RegSearchCommand(new Mock().Object);
+
+ var result = await command.ExecuteAsync("ABCDEFGH"); // 8 chars
+
+ result.ShouldBe("Registration too long.");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SpecialChars_ReturnsSpecialCharsMessage()
+ {
+ var command = new RegSearchCommand(new Mock().Object);
+
+ var result = await command.ExecuteAsync("AB-12C");
+
+ result.ShouldBe("Registration should not contain special characters");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ValidReg_ParsesHtmlResponse()
+ {
+ const string html = """
+
+
+
+
+
+
+
+ """;
+
+ var factory = TestHelpers.CreateHttpClientFactory(html);
+ var command = new RegSearchCommand(factory);
+
+ var result = await command.ExecuteAsync("AB12CDE");
+
+ result.ShouldContain("Blue");
+ result.ShouldContain("Ford");
+ result.ShouldContain("FOCUS");
+ result.ShouldContain("2019");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SpacesInReg_NormalisesBeforeSearch()
+ {
+ // If spaces are stripped, "AB 12 CDE" becomes "ab12cde" (7 chars, valid)
+ const string html = "";
+ var factory = TestHelpers.CreateHttpClientFactory(html);
+ var command = new RegSearchCommand(factory);
+
+ var result = await command.ExecuteAsync("AB 12 CD");
+
+ // Does not return an error about length or special chars
+ result.ShouldNotBe("Registration too long.");
+ result.ShouldNotBe("Registration should not contain special characters");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_HtmlWithError_ReturnsErrorText()
+ {
+ // An element with an empty value causes make[0] to throw, which triggers the catch block
+ const string html = """
+
+
+ Vehicle not found
+
+ """;
+
+ var factory = TestHelpers.CreateHttpClientFactory(html);
+ var command = new RegSearchCommand(factory);
+
+ var result = await command.ExecuteAsync("ZZ99ZZZ");
+
+ result.ShouldBe("Vehicle not found");
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs
new file mode 100644
index 0000000..0d1a09e
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs
@@ -0,0 +1,42 @@
+using Bottomly.Commands;
+using Bottomly.Tests.Helpers;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.Commands;
+
+public class UrbanSearchCommandTests
+{
+ [Fact]
+ public async Task ExecuteAsync_EmptyInput_ReturnsNull()
+ {
+ var mockFactory = new Mock();
+ var command = new UrbanSearchCommand(mockFactory.Object);
+
+ var result = await command.ExecuteAsync("");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithResults_ReturnsDefinition()
+ {
+ const string json = """{"list":[{"definition":"A domestic animal that owns you."}]}""";
+ var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json));
+
+ var result = await command.ExecuteAsync("cat");
+
+ result.ShouldBe("A domestic animal that owns you.");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoResults_ReturnsNull()
+ {
+ const string json = """{"list":[]}""";
+ var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json));
+
+ var result = await command.ExecuteAsync("xyznotaword");
+
+ result.ShouldBeNull();
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs
new file mode 100644
index 0000000..95f50dd
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs
@@ -0,0 +1,44 @@
+using Bottomly.Commands;
+using Bottomly.Tests.Helpers;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.Commands;
+
+public class WikipediaSearchCommandTests
+{
+ [Fact]
+ public async Task ExecuteAsync_EmptyInput_ReturnsNull()
+ {
+ var mockFactory = new Mock();
+ var command = new WikipediaSearchCommand(mockFactory.Object);
+
+ var result = await command.ExecuteAsync("");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithResults_ReturnsTitleAndLink()
+ {
+ const string json = """["cat",["Cat","Cat (disambiguation)"],["",""],["https://en.wikipedia.org/wiki/Cat","https://en.wikipedia.org/wiki/Cat_(disambiguation)"]]""";
+ var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json));
+
+ var result = await command.ExecuteAsync("cat");
+
+ result.ShouldNotBeNull();
+ result!.Text.ShouldBe("Cat");
+ result.Link.ShouldBe("https://en.wikipedia.org/wiki/Cat");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoResults_ReturnsNull()
+ {
+ const string json = """["unknownxyz",[],[],[]]""";
+ var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json));
+
+ var result = await command.ExecuteAsync("unknownxyz");
+
+ result.ShouldBeNull();
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs
new file mode 100644
index 0000000..3579b90
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs
@@ -0,0 +1,36 @@
+using System.Net;
+using Bottomly.Configuration;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace Bottomly.Tests.Helpers;
+
+internal static class TestHelpers
+{
+ public const string TestPrefix = "_";
+
+ public static IOptions CreateOptions(string prefix = TestPrefix) =>
+ Options.Create(new BottomlyOptions { Prefix = prefix });
+
+ public static IHttpClientFactory CreateHttpClientFactory(string responseContent,
+ HttpStatusCode statusCode = HttpStatusCode.OK)
+ {
+ var handler = new FakeHttpMessageHandler(responseContent, statusCode);
+ var client = new HttpClient(handler);
+ var factory = new Mock();
+ factory.Setup(f => f.CreateClient(It.IsAny())).Returns(client);
+ return factory.Object;
+ }
+}
+
+internal class FakeHttpMessageHandler(string content, HttpStatusCode statusCode = HttpStatusCode.OK)
+ : HttpMessageHandler
+{
+ protected override Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new HttpResponseMessage
+ {
+ StatusCode = statusCode,
+ Content = new StringContent(content)
+ });
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs
new file mode 100644
index 0000000..f0e06a3
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs
@@ -0,0 +1,207 @@
+using Bottomly.LlmBot;
+using Bottomly.Models;
+using Microsoft.Extensions.AI;
+using Shouldly;
+
+namespace Bottomly.Tests.LlmBot;
+
+public class BottomlyInputMessageTests
+{
+ [Fact]
+ public void Create_SetsUsernameAndText()
+ {
+ var msg = BottomlyInputMessage.Create("alice", "hello world");
+
+ msg.Username.ShouldBe("alice");
+ msg.Text.ShouldBe("hello world");
+ }
+}
+
+public class BottomlyUserNoteTests
+{
+ [Fact]
+ public void Create_SetsUsernameAndNote()
+ {
+ var note = BottomlyUserNote.Create("bob", "likes cats");
+
+ note.Username.ShouldBe("bob");
+ note.Note.ShouldBe("likes cats");
+ }
+}
+
+public class MessageHistoryContextTests
+{
+ [Fact]
+ public void Create_SetsMessageHistoryAndUserNotes()
+ {
+ var messages = new List { BottomlyInputMessage.Create("alice", "hi") };
+ var notes = new List { BottomlyUserNote.Create("alice", "some note") };
+
+ var ctx = MessageHistoryContext.Create(messages, notes);
+
+ ctx.MessageHistory.ShouldBe(messages);
+ ctx.UserNotes.ShouldBe(notes);
+ }
+
+ [Fact]
+ public void Create_EmptyLists_ResultsInEmptyContext()
+ {
+ var ctx = MessageHistoryContext.Create([], []);
+
+ ctx.MessageHistory.ShouldBeEmpty();
+ ctx.UserNotes.ShouldBeEmpty();
+ }
+}
+
+public class LlmResponseExtensionsTests
+{
+ [Fact]
+ public void ToSuccessResponse_ReturnsMsgResponseWithText()
+ {
+ var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello there")]);
+
+ var result = chatResponse.ToSuccessResponse();
+
+ result.ShouldBeOfType();
+ ((LlmMessageResponse)result).Message.ShouldBe("Hello there");
+ }
+
+ [Fact]
+ public void ToErrorResponse_TimeoutException_ReturnsTimeout()
+ {
+ var ex = new TimeoutException("timed out");
+
+ var result = ex.ToErrorResponse();
+
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public void ToErrorResponse_UsageException_ReturnsUsageExceeded()
+ {
+ var ex = new Exception("usage limit reached");
+
+ var result = ex.ToErrorResponse();
+
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public void ToErrorResponse_UnknownException_ReturnsUnknownError()
+ {
+ var ex = new InvalidOperationException("something went wrong");
+
+ var result = ex.ToErrorResponse();
+
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public void IsSuccess_LlmMessageResponse_ReturnsTrue()
+ {
+ var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "ok")]);
+ var response = chatResponse.ToSuccessResponse();
+
+ response.IsError().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsSuccess_LlmTimeoutResponse_ReturnsFalse()
+ {
+ LlmResponse response = new LlmTimeoutResponse();
+
+ response.IsError().ShouldBeTrue();
+ }
+}
+
+public class LlmBrokerExtensionsTests
+{
+ [Fact]
+ public void ToChatContext_IncludesMessageHistoryAndUserInfo()
+ {
+ var messages = new List
+ {
+ BottomlyInputMessage.Create("alice", "hello"),
+ BottomlyInputMessage.Create("bob", "world")
+ };
+ var notes = new List
+ {
+ BottomlyUserNote.Create("alice", "likes tea")
+ };
+ var ctx = MessageHistoryContext.Create(messages, notes);
+
+ var result = ctx.ToChatContext();
+
+ result.ShouldContain("alice: hello");
+ result.ShouldContain("bob: world");
+ result.ShouldContain("alice: likes tea");
+ result.ShouldContain("Begin Prompt Context");
+ result.ShouldContain("End Prompt Context");
+ }
+
+ [Fact]
+ public void ToChatPromptMessage_IncludesUsernameAndText()
+ {
+ var msg = BottomlyInputMessage.Create("carol", "what time is it?");
+
+ var result = msg.ToChatPromptMessage();
+
+ result.ShouldContain("carol");
+ result.ShouldContain("what time is it?");
+ result.ShouldContain("Begin Main Prompt");
+ result.ShouldContain("End Main Prompt");
+ }
+}
+
+public class FullPromptContextTests
+{
+ [Fact]
+ public void Create_PopulatesHistoryContextAndPromptingMessage()
+ {
+ var prompt = BottomlyInputMessage.Create("alice", "say something");
+ var ctx = MessageHistoryContext.Create([], []);
+
+ var fullCtx = FullPromptContext.Create(prompt, ctx);
+
+ fullCtx.HistoryContext.ShouldNotBeNull();
+ fullCtx.PromptingMessage.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void ToArray_ContainsThreeMessages()
+ {
+ var prompt = BottomlyInputMessage.Create("alice", "say something");
+ var ctx = MessageHistoryContext.Create([], []);
+
+ var array = FullPromptContext.Create(prompt, ctx).ToArray();
+
+ array.Length.ShouldBe(3);
+ array[0].Role.ShouldBe(ChatRole.System);
+ }
+
+ [Fact]
+ public void SystemPrompt_HasSystemRole() => FullPromptContext.SystemPrompt.Role.ShouldBe(ChatRole.System);
+
+ [Fact]
+ public void SystemPrompt_MentionsBottomly() => FullPromptContext.SystemPrompt.Text.ShouldContain("Bottomly");
+}
+
+public class MemberNoteTests
+{
+ [Fact]
+ public void Note_ContainsAllMemberInfo()
+ {
+ var member = new Member
+ {
+ FullName = "Alice Smith",
+ Gender = Gender.Female,
+ SassLevel = SassLevel.Frequent,
+ MiscInfo = "Drinks tea"
+ };
+
+ member.Note.ShouldContain("Alice Smith");
+ member.Note.ShouldContain("Female");
+ member.Note.ShouldContain("Frequent");
+ member.Note.ShouldContain("Drinks tea");
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs
new file mode 100644
index 0000000..8dfabed
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs
@@ -0,0 +1,105 @@
+using Bottomly.LlmBot;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.LlmBot;
+
+public class LlmClientTests
+{
+ private readonly Mock _mockChatClient = new();
+ private readonly LlmClient _broker;
+
+ public LlmClientTests()
+ {
+ _broker = new LlmClient(_mockChatClient.Object, NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task Respond_SuccessfulResponse_ReturnsLlmMessageResponse()
+ {
+ var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Quite right, sir.")]);
+ _mockChatClient
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(chatResponse);
+
+ var prompt = BottomlyInputMessage.Create("alice", "What's the weather?");
+ var context = MessageHistoryContext.Create([], []);
+
+ var result = await _broker.Respond(prompt, context);
+
+ result.ShouldBeOfType();
+ ((LlmMessageResponse)result).Message.ShouldBe("Quite right, sir.");
+ }
+
+ [Fact]
+ public async Task Respond_TimeoutException_ReturnsLlmTimeoutResponse()
+ {
+ _mockChatClient
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new TimeoutException("Request timed out"));
+
+ var prompt = BottomlyInputMessage.Create("alice", "Hello?");
+ var context = MessageHistoryContext.Create([], []);
+
+ var result = await _broker.Respond(prompt, context);
+
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task Respond_UsageExceededException_ReturnsLlmUsageExceededResponse()
+ {
+ _mockChatClient
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new Exception("usage limit exceeded"));
+
+ var prompt = BottomlyInputMessage.Create("alice", "Hello?");
+ var context = MessageHistoryContext.Create([], []);
+
+ var result = await _broker.Respond(prompt, context);
+
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task Respond_UnknownException_ReturnsLlmUnknownErrorResponse()
+ {
+ _mockChatClient
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("something unexpected"));
+
+ var prompt = BottomlyInputMessage.Create("alice", "Hello?");
+ var context = MessageHistoryContext.Create([], []);
+
+ var result = await _broker.Respond(prompt, context);
+
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task Respond_PassesPromptToClient()
+ {
+ var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Indeed.")]);
+ _mockChatClient
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(chatResponse);
+
+ var prompt = BottomlyInputMessage.Create("alice", "test message");
+ var context = MessageHistoryContext.Create([], []);
+
+ await _broker.Respond(prompt, context);
+
+ _mockChatClient.Verify(c =>
+ c.GetResponseAsync(
+ It.Is>(msgs => msgs.Count() == 3),
+ It.IsAny(),
+ It.IsAny()), Times.Once());
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs b/bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs
new file mode 100644
index 0000000..f6e1977
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs
@@ -0,0 +1,193 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.Repositories;
+
+public class KarmaRepositoryTests
+{
+ private readonly Mock> _mockCollection = new();
+ private readonly Mock _mockDatabase = new();
+ private readonly KarmaRepository _repository;
+
+ public KarmaRepositoryTests()
+ {
+ _mockDatabase
+ .Setup(d => d.GetCollection("karma", It.IsAny()))
+ .Returns(_mockCollection.Object);
+ _repository = new KarmaRepository(_mockDatabase.Object);
+ }
+
+ private static Mock> CreateBsonCursor(IEnumerable docs)
+ {
+ var cursor = new Mock>();
+ cursor.Setup(c => c.Current).Returns(docs.ToList());
+ cursor.SetupSequence(c => c.MoveNextAsync(It.IsAny()))
+ .ReturnsAsync(true)
+ .ReturnsAsync(false);
+ return cursor;
+ }
+
+ private void SetupAggregate(IEnumerable results)
+ {
+ var cursor = CreateBsonCursor(results);
+ _mockCollection
+ .Setup(c => c.Aggregate(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(cursor.Object);
+ }
+
+ [Fact]
+ public async Task AddAsync_CallsInsertOne()
+ {
+ var karma = new Karma { AwardedToUsername = "alice", AwardedByUsername = "bob", KarmaType = KarmaType.PozzyPoz };
+ _mockCollection
+ .Setup(c => c.InsertOneAsync(karma, It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await _repository.AddAsync(karma);
+
+ _mockCollection.Verify(
+ c => c.InsertOneAsync(karma, It.IsAny(), It.IsAny()),
+ Times.Once());
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_WithResults_ReturnsNetKarma()
+ {
+ var doc = new BsonDocument { { "_id", "alice" }, { "net_karma", 5 } };
+ SetupAggregate([doc]);
+
+ var result = await _repository.GetCurrentNetKarmaAsync("Alice");
+
+ result.ShouldBe(5);
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_NoResults_ReturnsZero()
+ {
+ SetupAggregate([]);
+
+ var result = await _repository.GetCurrentNetKarmaAsync("nobody");
+
+ result.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_LowercasesRecipient()
+ {
+ SetupAggregate([]);
+
+ // Should not throw — lowercase conversion is internal
+ await _repository.GetCurrentNetKarmaAsync("ALICE");
+ }
+
+ [Fact]
+ public async Task GetLeaderBoardAsync_ReturnsTopScorers()
+ {
+ var docs = new[]
+ {
+ new BsonDocument { { "_id", "alice" }, { "net_karma", 10 } },
+ new BsonDocument { { "_id", "bob" }, { "net_karma", 7 } },
+ new BsonDocument { { "_id", "carol" }, { "net_karma", 4 } }
+ };
+ SetupAggregate(docs);
+
+ var result = await _repository.GetLeaderBoardAsync(3);
+
+ result.Count.ShouldBe(3);
+ result[0].Username.ShouldBe("alice");
+ result[0].NetKarma.ShouldBe(10);
+ result[1].Username.ShouldBe("bob");
+ }
+
+ [Fact]
+ public async Task GetLeaderBoardAsync_LimitsResults()
+ {
+ var docs = new[]
+ {
+ new BsonDocument { { "_id", "alice" }, { "net_karma", 10 } },
+ new BsonDocument { { "_id", "bob" }, { "net_karma", 7 } },
+ new BsonDocument { { "_id", "carol" }, { "net_karma", 4 } },
+ new BsonDocument { { "_id", "dave" }, { "net_karma", 2 } }
+ };
+ SetupAggregate(docs);
+
+ var result = await _repository.GetLeaderBoardAsync(2);
+
+ result.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetLoserBoardAsync_ReturnsLowestScorers()
+ {
+ var docs = new[]
+ {
+ new BsonDocument { { "_id", "dave" }, { "net_karma", -5 } },
+ new BsonDocument { { "_id", "eve" }, { "net_karma", -3 } }
+ };
+ SetupAggregate(docs);
+
+ var result = await _repository.GetLoserBoardAsync(2);
+
+ result.Count.ShouldBe(2);
+ result[0].Username.ShouldBe("dave");
+ result[0].NetKarma.ShouldBe(-5);
+ }
+
+ [Fact]
+ public async Task GetKarmaReasonsAsync_SeparatesReasonedAndReasonless()
+ {
+ var docs = new[]
+ {
+ new BsonDocument
+ {
+ { "awarded_to_username", "alice" },
+ { "awarded_by_username", "bob" },
+ { "karma_type", "PozzyPoz" },
+ { "awarded", BsonDateTime.Create(DateTime.UtcNow) },
+ { "reason", "great work" }
+ },
+ new BsonDocument
+ {
+ { "awarded_to_username", "alice" },
+ { "awarded_by_username", "carol" },
+ { "karma_type", "PozzyPoz" },
+ { "awarded", BsonDateTime.Create(DateTime.UtcNow) },
+ { "reason", "" }
+ },
+ new BsonDocument
+ {
+ { "awarded_to_username", "alice" },
+ { "awarded_by_username", "dave" },
+ { "karma_type", "NeggyNeg" },
+ { "awarded", BsonDateTime.Create(DateTime.UtcNow) }
+ // no reason field
+ }
+ };
+ SetupAggregate(docs);
+
+ var result = await _repository.GetKarmaReasonsAsync("Alice");
+
+ result.Reasoned.Count.ShouldBe(1);
+ result.Reasoned[0].AwardedByUsername.ShouldBe("bob");
+ result.Reasoned[0].Reason.ShouldBe("great work");
+ result.Reasonless.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetKarmaReasonsAsync_EmptyResults_ReturnsEmpty()
+ {
+ SetupAggregate([]);
+
+ var result = await _repository.GetKarmaReasonsAsync("nobody");
+
+ result.Reasoned.ShouldBeEmpty();
+ result.Reasonless.ShouldBe(0);
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs b/bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs
new file mode 100644
index 0000000..bcf2723
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs
@@ -0,0 +1,158 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using MongoDB.Driver;
+using Moq;
+using Shouldly;
+
+namespace Bottomly.Tests.Repositories;
+
+public class MemberRepositoryTests
+{
+ private readonly Mock> _mockCollection = new();
+ private readonly Mock _mockDatabase = new();
+ private readonly MemberRepository _repository;
+
+ public MemberRepositoryTests()
+ {
+ _mockDatabase
+ .Setup(d => d.GetCollection("member", It.IsAny()))
+ .Returns(_mockCollection.Object);
+ _repository = new MemberRepository(_mockDatabase.Object);
+ }
+
+ private Mock> CreateCursor(IEnumerable items)
+ {
+ var cursor = new Mock>();
+ cursor.Setup(c => c.Current).Returns(items.ToList());
+ cursor.SetupSequence(c => c.MoveNextAsync(It.IsAny()))
+ .ReturnsAsync(true)
+ .ReturnsAsync(false);
+ return cursor;
+ }
+
+ private void SetupFind(IEnumerable results)
+ {
+ var cursor = CreateCursor(results);
+ _mockCollection
+ .Setup(c => c.FindAsync(
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(cursor.Object);
+ }
+
+ [Fact]
+ public async Task GetByUsernameAsync_WhenFound_ReturnsMember()
+ {
+ var member = new Member { Username = "alice", SlackId = "U1" };
+ SetupFind([member]);
+
+ var result = await _repository.GetByUsernameAsync("alice");
+
+ result.ShouldNotBeNull();
+ result!.Username.ShouldBe("alice");
+ }
+
+ [Fact]
+ public async Task GetByUsernameAsync_WhenNotFound_ReturnsNull()
+ {
+ SetupFind([]);
+
+ var result = await _repository.GetByUsernameAsync("nobody");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetBySlackIdAsync_WhenFound_ReturnsMember()
+ {
+ var member = new Member { Username = "bob", SlackId = "U2" };
+ SetupFind([member]);
+
+ var result = await _repository.GetBySlackIdAsync("U2");
+
+ result.ShouldNotBeNull();
+ result!.SlackId.ShouldBe("U2");
+ }
+
+ [Fact]
+ public async Task GetBySlackIdAsync_WhenNotFound_ReturnsNull()
+ {
+ SetupFind([]);
+
+ var result = await _repository.GetBySlackIdAsync("U_UNKNOWN");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetBySlackIdsAsync_ReturnsMatchingMembers()
+ {
+ var members = new List
+ {
+ new() { Username = "alice", SlackId = "U1" },
+ new() { Username = "bob", SlackId = "U2" }
+ };
+ SetupFind(members);
+
+ var result = await _repository.GetBySlackIdsAsync(["U1", "U2"]);
+
+ result.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task AddAsync_SingleMember_CallsInsertOne()
+ {
+ var member = new Member { Username = "carol", SlackId = "U3" };
+ _mockCollection
+ .Setup(c => c.InsertOneAsync(member, It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await _repository.AddAsync(member);
+
+ _mockCollection.Verify(
+ c => c.InsertOneAsync(member, It.IsAny(), It.IsAny()),
+ Times.Once());
+ }
+
+ [Fact]
+ public async Task AddAsync_MultipleMembers_CallsInsertMany()
+ {
+ var members = new List
+ {
+ new() { Username = "alice" },
+ new() { Username = "bob" }
+ };
+ _mockCollection
+ .Setup(c => c.InsertManyAsync(members, It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await _repository.AddAsync(members);
+
+ _mockCollection.Verify(
+ c => c.InsertManyAsync(members, It.IsAny(), It.IsAny()),
+ Times.Once());
+ }
+
+ [Fact]
+ public async Task UpdateInfoAsync_CallsUpdateOne()
+ {
+ _mockCollection
+ .Setup(c => c.UpdateOneAsync(
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new UpdateResult.Acknowledged(1, 1, null));
+
+ await _repository.UpdateInfoAsync("alice", "Alice Smith", Gender.Female, SassLevel.Moderate, "Likes tea");
+
+ _mockCollection.Verify(
+ c => c.UpdateOneAsync(
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once());
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs
new file mode 100644
index 0000000..74ff5b2
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs
@@ -0,0 +1,101 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GetCurrentKarmaReasonsHandlerTests
+{
+ private readonly GetCurrentKarmaReasonsHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+ private readonly Mock _mockMemberRepo = new();
+
+ public GetCurrentKarmaReasonsHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var parser = new SlackParser(_mockMemberRepo.Object);
+ _handler = new GetCurrentKarmaReasonsHandler(_mockKarmaRepo.Object, parser, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text, string user = "U_sender") =>
+ new() { Text = text, User = user, Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_WithRecipient_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_reasons alice")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_NoRecipient_ReturnsTrue() => _handler.CanHandle(CreateMessage("_reasons")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() =>
+ _handler.CanHandle(CreateMessage("hello world")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_PlainRecipient_CallsCommandWithRecipient()
+ {
+ _mockKarmaRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync(
+ new KarmaReasonsResult(0, new List().AsReadOnly()));
+
+ await _handler.HandleAsync(CreateMessage("_reasons alice"));
+
+ _mockKarmaRepo.Verify(r => r.GetKarmaReasonsAsync("alice"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_NoRecipient_CallsCommandWithMessageUser()
+ {
+ _mockKarmaRepo.Setup(r => r.GetKarmaReasonsAsync("U_sender")).ReturnsAsync(
+ new KarmaReasonsResult(0, new List().AsReadOnly()));
+
+ await _handler.HandleAsync(CreateMessage("_reasons "));
+
+ _mockKarmaRepo.Verify(r => r.GetKarmaReasonsAsync("U_sender"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithResult_SendsFormattedResponse()
+ {
+ var reasoned = new List
+ {
+ new()
+ {
+ AwardedByUsername = "bob", AwardedToUsername = "alice", Reason = "great work",
+ KarmaType = KarmaType.PozzyPoz
+ },
+ new()
+ {
+ AwardedByUsername = "carol", AwardedToUsername = "alice", Reason = "bad work",
+ KarmaType = KarmaType.NeggyNeg
+ }
+ }.AsReadOnly();
+ _mockKarmaRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync(new KarmaReasonsResult(1, reasoned));
+
+ await _handler.HandleAsync(CreateMessage("_reasons alice"));
+
+ _mockBroker.Verify(b => b.SendDmAsync(
+ It.Is(s =>
+ s.Contains("Recent Karma for alice") &&
+ s.Contains("Recently awarded with no reason: 1") &&
+ s.Contains("++ from bob for \"great work\"") &&
+ s.Contains("-- from carol for \"bad work\"")),
+ "U_sender"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_reasons -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Karma Reasons")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs
new file mode 100644
index 0000000..8770bb4
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs
@@ -0,0 +1,87 @@
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Slack.ReactionHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GetCurrentNetKarmaHandlerTests
+{
+ private readonly GetCurrentNetKarmaHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+ private readonly Mock _mockMemberRepo = new();
+
+ public GetCurrentNetKarmaHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var parser = new SlackParser(_mockMemberRepo.Object);
+ _handler = new GetCurrentNetKarmaHandler(
+ _mockKarmaRepo.Object,
+ new KarmaReactionMap(), parser,
+ _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text, string user = "U_sender") =>
+ new() { Text = text, User = user, Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_WithPlainRecipient_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_karma recipient")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_WithSlackIdRecipient_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_karma <@U12345>")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_NoRecipient_ReturnsTrue() => _handler.CanHandle(CreateMessage("_karma")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() =>
+ _handler.CanHandle(CreateMessage("hello world")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_PlainRecipient_CallsCommandWithRecipient()
+ {
+ _mockKarmaRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(3);
+
+ await _handler.HandleAsync(CreateMessage("_karma alice"));
+
+ _mockKarmaRepo.Verify(r => r.GetCurrentNetKarmaAsync("alice"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_NoRecipient_CallsCommandWithMessageUser()
+ {
+ _mockKarmaRepo.Setup(r => r.GetCurrentNetKarmaAsync("U_sender")).ReturnsAsync(0);
+
+ await _handler.HandleAsync(CreateMessage("_karma "));
+
+ _mockKarmaRepo.Verify(r => r.GetCurrentNetKarmaAsync("U_sender"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithResult_SendsFormattedResponse()
+ {
+ _mockKarmaRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(0);
+
+ await _handler.HandleAsync(CreateMessage("_karma alice"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("alice: 0", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_karma -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Get Current Karma")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs
new file mode 100644
index 0000000..6886954
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs
@@ -0,0 +1,100 @@
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GetLeaderBoardHandlerTests
+{
+ private readonly GetLeaderBoardHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+
+ public GetLeaderBoardHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ _handler = new GetLeaderBoardHandler(_mockKarmaRepo.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEventNoSize_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_leaderboard")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_ValidEventWithSize_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_leaderboard 5")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_NoSize_CallsCommandWithDefaultSize3()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_leaderboard "));
+
+ _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithSize10_CallsCommandWithSize10()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(10)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_leaderboard 10"));
+
+ _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(10), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithInvalidSize_CallsCommandWithDefaultSize3()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_leaderboard abc"));
+
+ _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithSize0_CallsCommandWithDefaultSize3()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_leaderboard 0"));
+
+ _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithResult_SendsFormattedResponse()
+ {
+ var scores = new List { new("name1", 2), new("name2", 1) }.AsReadOnly();
+ _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(scores);
+
+ await _handler.HandleAsync(CreateMessage("_leaderboard "));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(
+ $"name1: 2{Environment.NewLine}name2: 1",
+ "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_leaderboard -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Get Leaderboard")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs
new file mode 100644
index 0000000..264f80c
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs
@@ -0,0 +1,100 @@
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GetLoserBoardHandlerTests
+{
+ private readonly GetLoserBoardHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+
+ public GetLoserBoardHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ _handler = new GetLoserBoardHandler(_mockKarmaRepo.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEventNoSize_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_loserboard")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_ValidEventWithSize_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_loserboard 5")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_NoSize_CallsCommandWithDefaultSize3()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_loserboard "));
+
+ _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithSize10_CallsCommandWithSize10()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(10)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_loserboard 10"));
+
+ _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(10), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithInvalidSize_CallsCommandWithDefaultSize3()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_loserboard abc"));
+
+ _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithSize0_CallsCommandWithDefaultSize3()
+ {
+ _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(new List().AsReadOnly());
+
+ await _handler.HandleAsync(CreateMessage("_loserboard 0"));
+
+ _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_WithResult_SendsFormattedResponse()
+ {
+ var scores = new List { new("name1", -2), new("name2", -5) }.AsReadOnly();
+ _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(scores);
+
+ await _handler.HandleAsync(CreateMessage("_loserboard "));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(
+ $"name1: -2{Environment.NewLine}name2: -5",
+ "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_loserboard -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Get Loserboard")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs
new file mode 100644
index 0000000..922eb83
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs
@@ -0,0 +1,73 @@
+using Bottomly.Commands;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GiphyHandlerTests
+{
+ private readonly GiphyHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public GiphyHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var mockFactory = new Mock();
+ _mockCommand = new Mock(mockFactory.Object, options);
+ _handler = new GiphyHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_gif cats")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_CallsCommandWithTerm()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync((string?)null);
+
+ await _handler.HandleAsync(CreateMessage("_gif cats"));
+
+ _mockCommand.Verify(c => c.ExecuteAsync("cats"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_WithResult_SendsResult()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync("https://giphy.com/cat.gif");
+
+ await _handler.HandleAsync(CreateMessage("_gif cats"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("https://giphy.com/cat.gif", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_NullResult_SendsNoGifsMessage()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((string?)null);
+
+ await _handler.HandleAsync(CreateMessage("_gif xyz"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("No gifs found for \"xyz\"", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_gif -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Giphy")), "C1", null), Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs
new file mode 100644
index 0000000..c6b6741
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs
@@ -0,0 +1,75 @@
+using Bottomly.Commands;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GoogleHandlerTests
+{
+ private readonly GoogleHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public GoogleHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ _mockCommand = new Mock(options);
+ _handler = new GoogleHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_g a valid google command")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() =>
+ _handler.CanHandle(CreateMessage("no prefix here")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_CallsCommandWithQuery()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("some query")).ReturnsAsync((GoogleSearchResult?)null);
+
+ await _handler.HandleAsync(CreateMessage("_g some query"));
+
+ _mockCommand.Verify(c => c.ExecuteAsync("some query"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("dotnet"))
+ .ReturnsAsync(new GoogleSearchResult("DotNet", "https://dotnet.microsoft.com"));
+
+ await _handler.HandleAsync(CreateMessage("_g dotnet"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("DotNet https://dotnet.microsoft.com", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((GoogleSearchResult?)null);
+
+ await _handler.HandleAsync(CreateMessage("_g xyz"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("No results found for \"xyz\"", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_g -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Google")), "C1", null), Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs
new file mode 100644
index 0000000..0727841
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs
@@ -0,0 +1,75 @@
+using Bottomly.Commands;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class GoogleImageHandlerTests
+{
+ private readonly GoogleImageHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public GoogleImageHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ _mockCommand = new Mock(options);
+ _handler = new GoogleImageHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_gi cats")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() =>
+ _handler.CanHandle(CreateMessage("no prefix here")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_CallsCommandWithQuery()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync((GoogleSearchResult?)null);
+
+ await _handler.HandleAsync(CreateMessage("_gi cats"));
+
+ _mockCommand.Verify(c => c.ExecuteAsync("cats"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("cats"))
+ .ReturnsAsync(new GoogleSearchResult("Cute Cat", "https://example.com/cat.jpg"));
+
+ await _handler.HandleAsync(CreateMessage("_gi cats"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("Cute Cat https://example.com/cat.jpg", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((GoogleSearchResult?)null);
+
+ await _handler.HandleAsync(CreateMessage("_gi xyz"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("No image results found for \"xyz\"", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_gi -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Google Image")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs
new file mode 100644
index 0000000..6ffb017
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs
@@ -0,0 +1,72 @@
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class HelpHandlerTests
+{
+ private readonly Mock _mockBroker = new();
+
+ private HelpHandler CreateHandler(IEnumerable? handlers = null)
+ {
+ var options = TestHelpers.CreateOptions();
+ return new HelpHandler(
+ handlers ?? Enumerable.Empty(),
+ _mockBroker.Object,
+ options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text, string user = "U1") =>
+ new() { Text = text, User = user, Channel = "C1", Ts = "ts1" };
+
+ [Theory]
+ [InlineData("_help")]
+ [InlineData("_?")]
+ [InlineData("_list")]
+ public void CanHandle_HelpEvent_ReturnsTrue(string text)
+ {
+ var handler = CreateHandler();
+ handler.CanHandle(CreateMessage(text)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ handler.CanHandle(CreateMessage("hello")).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_SendsDmWithAllHandlerHelpMessages()
+ {
+ var mockH1 = new Mock();
+ mockH1.Setup(h => h.BuildHelpMessage()).Returns("Handler1 Help");
+ var mockH2 = new Mock();
+ mockH2.Setup(h => h.BuildHelpMessage()).Returns("Handler2 Help");
+
+ var handler = CreateHandler(new[] { mockH1.Object, mockH2.Object });
+ var message = CreateMessage("_help");
+
+ await handler.HandleAsync(message);
+
+ _mockBroker.Verify(b => b.SendDmAsync(
+ It.Is(s => s.Contains("Handler1 Help") && s.Contains("Handler2 Help")),
+ "U1"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpForSelf()
+ {
+ var handler = CreateHandler();
+
+ await handler.HandleAsync(CreateMessage("_help -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Help")), "C1", null), Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs
new file mode 100644
index 0000000..439869b
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs
@@ -0,0 +1,66 @@
+using Bottomly.Commands;
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers;
+
+public class DecrementMessageKarmaEventHandlerTests
+{
+ private readonly DecrementMessageKarmaEventHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+ private readonly Mock _mockMemberRepo = new();
+
+ public DecrementMessageKarmaEventHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var command = new AddKarmaCommand(_mockKarmaRepo.Object);
+ var parser = new SlackParser(_mockMemberRepo.Object);
+ _handler = new DecrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object,
+ options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text, string user = "U_sender") =>
+ new() { Text = text, User = user, Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("-- recipient")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_ExecutesCommandAndSendsReaction()
+ {
+ _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask);
+ _mockMemberRepo.Setup(r => r.GetByUsernameAsync("recipient"))
+ .ReturnsAsync(new Member { Username = "recipient" });
+ _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await _handler.HandleAsync(CreateMessage("-- recipient"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "recipient" &&
+ k.KarmaType == KarmaType.NeggyNeg)), Times.Once());
+ _mockBroker.Verify(b => b.SendReactionAsync("robot_face", "C1", "ts1"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ // Help event for karma handlers is "-- -?" (no prefix)
+ await _handler.HandleAsync(CreateMessage("-- -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Neggy-neg")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs
new file mode 100644
index 0000000..7e99892
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs
@@ -0,0 +1,66 @@
+using Bottomly.Commands;
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers;
+
+public class IncrementMessageKarmaEventHandlerTests
+{
+ private readonly IncrementMessageKarmaEventHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+ private readonly Mock _mockMemberRepo = new();
+
+ public IncrementMessageKarmaEventHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var command = new AddKarmaCommand(_mockKarmaRepo.Object);
+ var parser = new SlackParser(_mockMemberRepo.Object);
+ _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object,
+ options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text, string user = "U_sender") =>
+ new() { Text = text, User = user, Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("++ recipient")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_ExecutesCommandAndSendsReaction()
+ {
+ _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask);
+ _mockMemberRepo.Setup(r => r.GetByUsernameAsync("recipient"))
+ .ReturnsAsync(new Member { Username = "recipient" });
+ _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await _handler.HandleAsync(CreateMessage("++ recipient"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "recipient" &&
+ k.KarmaType == KarmaType.PozzyPoz)), Times.Once());
+ _mockBroker.Verify(b => b.SendReactionAsync("robot_face", "C1", "ts1"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ // Help event for karma handlers is "++ -?" (no prefix)
+ await _handler.HandleAsync(CreateMessage("++ -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Pozzy-poz")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs
new file mode 100644
index 0000000..120ca15
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs
@@ -0,0 +1,137 @@
+using Bottomly.Commands;
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers;
+
+public class KarmaHandlerCommandParsingTests
+{
+ private readonly IncrementMessageKarmaEventHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockKarmaRepo = new();
+ private readonly Mock _mockMemberRepo = new();
+
+ public KarmaHandlerCommandParsingTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var command = new AddKarmaCommand(_mockKarmaRepo.Object);
+ var parser = new SlackParser(_mockMemberRepo.Object);
+ _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object,
+ options,
+ NullLogger.Instance);
+
+ _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask);
+ _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "someuser", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public async Task ParseCommandText_ReasonlessOneWordRecipient()
+ {
+ _mockMemberRepo.Setup(r => r.GetByUsernameAsync("recipient"))
+ .ReturnsAsync(new Member { Username = "recipient" });
+
+ await _handler.HandleAsync(CreateMessage("++ recipient"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "recipient" && k.Reason == "")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonlessOneWordSlackUserRecipient()
+ {
+ _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("SLACK1"))
+ .ReturnsAsync(new Member { Username = "slackuser", SlackId = "SLACK1" });
+ _mockMemberRepo.Setup(r => r.GetByUsernameAsync("slackuser"))
+ .ReturnsAsync(new Member { Username = "slackuser" });
+
+ await _handler.HandleAsync(CreateMessage("++ <@SLACK1>"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "slackuser" && k.Reason == "")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonlessMultiWordRecipient()
+ {
+ _mockMemberRepo.Setup(r => r.GetByUsernameAsync("some")).ReturnsAsync((Member?)null);
+
+ await _handler.HandleAsync(CreateMessage("++ some recipient"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "some recipient" && k.Reason == "")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonedOneWordRecipient()
+ {
+ // "++ recipient for some reason" - splits on " for " → recipient="recipient", reason="some reason"
+ await _handler.HandleAsync(CreateMessage("++ recipient for some reason"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "recipient" && k.Reason == "some reason")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonedMultiWordRecipient()
+ {
+ // "++ some recipient for some reason" - splits on " for " → recipient="some recipient", reason="some reason"
+ await _handler.HandleAsync(CreateMessage("++ some recipient for some reason"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "some recipient" && k.Reason == "some reason")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonedUserRecipientNoFor()
+ {
+ // "++ username is sexy" - no " for ", first word "username" is a known user → recipient="username", remaining="is sexy"
+ _mockMemberRepo.Setup(r => r.GetByUsernameAsync("username")).ReturnsAsync(new Member { Username = "username" });
+
+ await _handler.HandleAsync(CreateMessage("++ username is sexy"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "username" && k.Reason == "is sexy")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonedUserRecipientWithFor()
+ {
+ // "++ username for being sexy" - splits on " for " → recipient="username", reason="being sexy"
+ await _handler.HandleAsync(CreateMessage("++ username for being sexy"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "username" && k.Reason == "being sexy")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_ReasonedSlackIdRecipientWithFor()
+ {
+ _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("SLACK1"))
+ .ReturnsAsync(new Member { Username = "slackuser", SlackId = "SLACK1" });
+
+ await _handler.HandleAsync(CreateMessage("++ <@SLACK1> for being sexy"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "slackuser" && k.Reason == "being sexy")), Times.Once());
+ }
+
+ [Fact]
+ public async Task ParseCommandText_MultipleFor()
+ {
+ // "++ username for being sexy and for being nice" → reason="being sexy and for being nice"
+ await _handler.HandleAsync(CreateMessage("++ username for being sexy and for being nice"));
+
+ _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k =>
+ k.AwardedToUsername == "username" && k.Reason == "being sexy and for being nice")), Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs
new file mode 100644
index 0000000..1462ddf
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs
@@ -0,0 +1,101 @@
+using Bottomly.Commands;
+using Bottomly.Configuration;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class RegHandlerTests
+{
+ private readonly RegHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public RegHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ _mockCommand = new Mock(new Mock().Object);
+ _handler = new RegHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_reg AB12CDE")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_JustTrigger_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_reg")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() =>
+ _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_WithResult_SendsResult()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync(It.IsAny()))
+ .ReturnsAsync("Red Ford Focus (2019)");
+
+ await _handler.HandleAsync(CreateMessage("_reg AB12CDE"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("Red Ford Focus (2019)", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_reg -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(
+ It.Is(s => s.Contains("Reg Lookup")), "C1", null), Times.Once());
+ }
+}
+
+public class TestHandlerTests
+{
+ private readonly TestHandler _handler;
+ private readonly Mock _mockBroker = new();
+
+ public TestHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ _handler = new TestHandler(_mockBroker.Object, options, NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() =>
+ _handler.CanHandle(CreateMessage("_test")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() =>
+ _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_SendsOkMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_test"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("OK", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_test -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(
+ It.Is(s => s.Contains("Test")), "C1", null), Times.Once());
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs
new file mode 100644
index 0000000..e63c524
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs
@@ -0,0 +1,67 @@
+using Bottomly.Commands;
+using Bottomly.Configuration;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class ReleaseHandlerTests
+{
+ private readonly ReleaseHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public ReleaseHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var releaseOptions = Options.Create(new BottomlyOptions { GitHubToken = "token" });
+ _mockCommand = new Mock(releaseOptions, NullLogger.Instance);
+ _handler = new ReleaseHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_release")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_WithResult_SendsResult()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync("Latest Release: *v1.0* v1.0");
+
+ await _handler.HandleAsync(CreateMessage("_release"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("Latest Release: *v1.0* v1.0", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_NullResult_SendsUnableMessage()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync((string?)null);
+
+ await _handler.HandleAsync(CreateMessage("_release"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("Unable to retrieve latest release info.", "C1", null),
+ Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_release -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Release")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs
new file mode 100644
index 0000000..d9987ba
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs
@@ -0,0 +1,76 @@
+using Bottomly.Commands;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class UrbanHandlerTests
+{
+ private readonly UrbanHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public UrbanHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var mockFactory = new Mock();
+ _mockCommand = new Mock(mockFactory.Object);
+ _handler = new UrbanHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_ud whatever")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_CallsCommandWithTerm()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync((string?)null);
+
+ await _handler.HandleAsync(CreateMessage("_ud hello"));
+
+ _mockCommand.Verify(c => c.ExecuteAsync("hello"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_WithResult_SendsReplyResponse()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync("a greeting");
+ var message = CreateMessage("_ud hello");
+
+ await _handler.HandleAsync(message);
+
+ // asReply:true means threadTs = message.Ts = "ts1"
+ _mockBroker.Verify(b => b.SendMessageAsync("a greeting", "C1", "ts1"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_NullResult_SendsExerciseMessage()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((string?)null);
+
+ await _handler.HandleAsync(CreateMessage("_ud xyz"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("Left as an exercise for the reader.", "C1", "ts1"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_ud -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Urban Dictionary")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs
new file mode 100644
index 0000000..f9e0257
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs
@@ -0,0 +1,76 @@
+using Bottomly.Commands;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers;
+using Bottomly.Tests.Helpers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet.Events;
+
+namespace Bottomly.Tests.Slack.EventHandlers;
+
+public class WikipediaHandlerTests
+{
+ private readonly WikipediaHandler _handler;
+ private readonly Mock _mockBroker = new();
+ private readonly Mock _mockCommand;
+
+ public WikipediaHandlerTests()
+ {
+ var options = TestHelpers.CreateOptions();
+ var mockFactory = new Mock();
+ _mockCommand = new Mock(mockFactory.Object);
+ _handler = new WikipediaHandler(_mockCommand.Object, _mockBroker.Object, options,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text) =>
+ new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" };
+
+ [Fact]
+ public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_wik octopus")).ShouldBeTrue();
+
+ [Fact]
+ public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse();
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_CallsCommandWithTerm()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("octopus")).ReturnsAsync((WikipediaResult?)null);
+
+ await _handler.HandleAsync(CreateMessage("_wik octopus"));
+
+ _mockCommand.Verify(c => c.ExecuteAsync("octopus"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("octopus"))
+ .ReturnsAsync(new WikipediaResult("Octopus", "https://en.wikipedia.org/wiki/Octopus"));
+
+ await _handler.HandleAsync(CreateMessage("_wik octopus"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("Octopus https://en.wikipedia.org/wiki/Octopus", "C1", null),
+ Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage()
+ {
+ _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((WikipediaResult?)null);
+
+ await _handler.HandleAsync(CreateMessage("_wik xyz"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync("No results found for \"xyz\"", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_HelpEvent_SendsHelpMessage()
+ {
+ await _handler.HandleAsync(CreateMessage("_wik -?"));
+
+ _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Wikipedia")), "C1", null),
+ Times.Once());
+ }
+}
\ No newline at end of file
diff --git a/bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs b/bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs
new file mode 100644
index 0000000..5e4514e
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs
@@ -0,0 +1,64 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet;
+using SlackNet.WebApi;
+
+namespace Bottomly.Tests.Slack;
+
+public class MemberlistPopulatorTests
+{
+ private readonly Mock _mockSlack = new();
+ private readonly Mock _mockUsers = new();
+ private readonly Mock _mockRepo = new();
+ private readonly MemberlistPopulator _populator;
+
+ public MemberlistPopulatorTests()
+ {
+ _mockSlack.Setup(s => s.Users).Returns(_mockUsers.Object);
+ _populator = new MemberlistPopulator(_mockSlack.Object, _mockRepo.Object);
+ }
+
+ [Fact]
+ public async Task PopulateMembers_AlreadySeeded_ReturnsEmptyAndSkipsSlack()
+ {
+ _mockRepo.Setup(r => r.GetByUsernameAsync("owen"))
+ .ReturnsAsync(new Member { Username = "owen" });
+
+ var result = await _populator.PopulateMembers();
+
+ result.ShouldBeEmpty();
+ _mockUsers.Verify(u => u.List(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never());
+ }
+
+ [Fact]
+ public async Task PopulateMembers_NotSeeded_FetchesAndSavesMembers()
+ {
+ _mockRepo.Setup(r => r.GetByUsernameAsync("owen")).ReturnsAsync((Member?)null);
+
+ var slackUsers = new UserListResponse
+ {
+ Members =
+ [
+ new User { Id = "U1", Name = "alice", Deleted = false },
+ new User { Id = "U2", Name = "bob", Deleted = false },
+ new User { Id = "U3", Name = "deleted_user", Deleted = true }
+ ]
+ };
+ _mockUsers.Setup(u => u.List(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(slackUsers);
+ _mockRepo.Setup(r => r.AddAsync(It.IsAny>())).Returns(Task.CompletedTask);
+
+ var result = await _populator.PopulateMembers();
+
+ result.Count.ShouldBe(2);
+ result.ShouldContain(m => m.Username == "alice" && m.SlackId == "U1");
+ result.ShouldContain(m => m.Username == "bob" && m.SlackId == "U2");
+ _mockRepo.Verify(r => r.AddAsync(It.Is>(members => members.Count() == 2)), Times.Once());
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs
new file mode 100644
index 0000000..8862698
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs
@@ -0,0 +1,62 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack.MembershipEventHandlers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using SlackNet;
+using SlackNet.Events;
+using SlackNet.WebApi;
+
+namespace Bottomly.Tests.Slack.MembershipEventHandlers;
+
+public class MemberJoinedEventHandlerTests
+{
+ private readonly Mock _mockRepo = new();
+ private readonly Mock _mockSlack = new();
+ private readonly Mock _mockUsers = new();
+ private readonly MemberJoinedEventHandler _handler;
+
+ public MemberJoinedEventHandlerTests()
+ {
+ _mockSlack.Setup(s => s.Users).Returns(_mockUsers.Object);
+ _handler = new MemberJoinedEventHandler(
+ _mockRepo.Object,
+ _mockSlack.Object,
+ NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WrongChannel_DoesNothing()
+ {
+ var ev = new MemberJoinedChannel { Channel = "#random", User = "U1" };
+
+ await _handler.ExecuteAsync(ev);
+
+ _mockUsers.Verify(u => u.Info(It.IsAny()), Times.Never());
+ _mockRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Never());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NullMemberInfo_DoesNotSave()
+ {
+ var ev = new MemberJoinedChannel { Channel = "#general", User = "U1" };
+ _mockUsers.Setup(u => u.Info("U1")).ReturnsAsync((User?)null);
+
+ await _handler.ExecuteAsync(ev);
+
+ _mockRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Never());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ValidEvent_AddsNewMember()
+ {
+ var ev = new MemberJoinedChannel { Channel = "#general", User = "U1" };
+ _mockUsers.Setup(u => u.Info("U1")).ReturnsAsync(new User { Id = "U1", Name = "alice" });
+ _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask);
+
+ await _handler.ExecuteAsync(ev);
+
+ _mockRepo.Verify(r => r.AddAsync(It.Is(m =>
+ m.SlackId == "U1" && m.Username == "alice")), Times.Once());
+ }
+}
diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs
new file mode 100644
index 0000000..0f22447
--- /dev/null
+++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs
@@ -0,0 +1,136 @@
+using Bottomly.LlmBot;
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Slack;
+using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Shouldly;
+using SlackNet;
+using SlackNet.Events;
+using SlackNet.WebApi;
+
+namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling;
+
+public class ConversationMessageHandlerTests
+{
+ private readonly Mock _mockLlmBroker = new();
+ private readonly Mock _mockSlackBroker = new();
+ private readonly Mock _mockApiClient = new();
+ private readonly Mock _mockConversations = new();
+ private readonly Mock _mockMemberRepo = new();
+ private readonly ConversationMessageHandler _handler;
+
+ public ConversationMessageHandlerTests()
+ {
+ _mockApiClient.Setup(a => a.Conversations).Returns(_mockConversations.Object);
+
+ _handler = new ConversationMessageHandler(
+ _mockLlmBroker.Object,
+ _mockSlackBroker.Object,
+ _mockApiClient.Object,
+ _mockMemberRepo.Object,
+ NullLogger.Instance);
+ }
+
+ private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1", string? threadTs = null) =>
+ new() { Text = text, User = user, Channel = channel, Ts = "ts1", ThreadTs = threadTs };
+
+ [Theory]
+ [InlineData("hey bottomly what do you think?")]
+ [InlineData("bottomly, help me")]
+ [InlineData("I asked bottomly already")]
+ public void CanHandle_MessageContainsBottomly_ReturnsTrue(string text) =>
+ _handler.CanHandle(CreateMessage(text)).ShouldBeTrue();
+
+ [Theory]
+ [InlineData("hello there")]
+ [InlineData("_karma alice")]
+ [InlineData("")]
+ public void CanHandle_MessageWithoutBottomly_ReturnsFalse(string text) =>
+ _handler.CanHandle(CreateMessage(text)).ShouldBeFalse();
+
+ [Fact]
+ public void BuildHelpMessage_ReturnsEmptyString() =>
+ _handler.BuildHelpMessage().ShouldBeEmpty();
+
+ [Fact]
+ public async Task HandleAsync_SuccessfulLlmResponse_SendsReplyToChannel()
+ {
+ SetupConversationHistory("C1", []);
+ SetupMembers([new Member { SlackId = "U1", Username = "alice" }]);
+ _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(LlmMessageResponse.Create("Indeed, sir."));
+
+ await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1"));
+
+ _mockSlackBroker.Verify(b => b.SendMessageAsync("Indeed, sir.", "C1", null), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_BuildsContextFromHistory()
+ {
+ var historyMessages = new List
+ {
+ new() { User = "U1", Text = "first message", Ts = "1000.000" },
+ new() { User = "U2", Text = "second message", Ts = "1001.000" }
+ };
+ SetupConversationHistory("C1", historyMessages);
+ SetupMembers([
+ new Member { SlackId = "U1", Username = "alice" },
+ new Member { SlackId = "U2", Username = "bob" }
+ ]);
+ _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(LlmMessageResponse.Create("Of course."));
+
+ await _handler.HandleAsync(CreateMessage("bottomly something", "U1", "C1"));
+
+ _mockLlmBroker.Verify(b => b.Respond(It.IsAny(), It.IsAny()), Times.Once());
+ }
+
+ [Theory]
+ [InlineData(nameof(LlmTimeoutResponse))]
+ [InlineData(nameof(LlmUsageExceededResponse))]
+ [InlineData(nameof(LlmUnknownErrorResponse))]
+ public async Task HandleAsync_ErrorLlmResponse_SendsReplyToMessage(string responseType)
+ {
+ SetupConversationHistory("C1", []);
+ SetupMembers([new Member { SlackId = "U1", Username = "alice" }]);
+ LlmResponse errorResponse = responseType switch
+ {
+ nameof(LlmTimeoutResponse) => new LlmTimeoutResponse(),
+ nameof(LlmUsageExceededResponse) => new LlmUsageExceededResponse(),
+ _ => new LlmUnknownErrorResponse()
+ };
+ _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(errorResponse);
+
+ await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1"));
+
+ _mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny(), "C1", "ts1"), Times.Once());
+ }
+
+ [Fact]
+ public async Task HandleAsync_ErrorLlmResponseInThread_SendsReplyToThread()
+ {
+ SetupConversationHistory("C1", []);
+ SetupMembers([new Member { SlackId = "U1", Username = "alice" }]);
+ _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new LlmTimeoutResponse());
+
+ await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1", threadTs: "thread_ts1"));
+
+ _mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny(), "C1", "thread_ts1"), Times.Once());
+ }
+
+ private void SetupConversationHistory(string channel, List messages) =>
+ _mockConversations
+ .Setup(c => c.History(channel, It.IsAny(), It.IsAny