using Microsoft.Data.Sqlite; using Nonemm.Core; namespace Nonemm.Storage.Tests; public class SqliteLogStoreTests : IDisposable { private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.s3db"); public void Dispose() { SqliteConnection.ClearAllPools(); File.Delete(path); GC.SuppressFinalize(this); } private static Qso Contact(string call, DateTime at, int contestNumber = 1) => new() { Id = Qso.NewId(), TimestampUtc = at, Call = Callsign.Parse(call), Frequency = Frequency.FromKilohertz(14_025.5), Mode = Modes.Cw, ContestName = "CQWW", ContestNumber = contestNumber, SentReport = "599", ReceivedReport = "599", Zone = 14, Points = 3, IsMultiplier1 = true, CountryPrefix = "DL", Continent = "EU", WpxPrefix = "DL1", }; [Fact] public void ContactSurvivesARoundTrip() { DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc); using SqliteLogStore store = SqliteLogStore.Open(path); Qso written = store.Add(Contact("DL1ABC", at)); Qso read = store.Qsos(1).Single(); Assert.Equal(written.Id, read.Id); Assert.Equal("DL1ABC", read.Call.Text); Assert.Equal(at, read.TimestampUtc); Assert.Equal(14_025_500, read.Frequency.Hertz); Assert.Equal(Modes.Cw, read.Mode); Assert.Equal(14, read.Zone); Assert.Equal("EU", read.Continent); } [Fact] public void SecondContactInTheSameSecondMovesOnASecond() { DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc); using SqliteLogStore store = SqliteLogStore.Open(path); store.Add(Contact("DL1ABC", at)); Qso second = store.Add(Contact("DL1ABC", at)); Assert.Equal(at.AddSeconds(1), second.TimestampUtc); Assert.Equal(2, store.Qsos(1).Count); } [Fact] public void EditedContactKeepsItsIdentity() { using SqliteLogStore store = SqliteLogStore.Open(path); Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc))); store.Update(written with { ReceivedReport = "579", Zone = 15 }); Qso read = store.Qsos(1).Single(); Assert.Equal(written.Id, read.Id); Assert.Equal("579", read.ReceivedReport); Assert.Equal(15, read.Zone); } [Fact] public void DeletedContactIsGone() { using SqliteLogStore store = SqliteLogStore.Open(path); Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc))); store.Delete(written.Id); Assert.Empty(store.Qsos(1)); } [Fact] public void ContestsAreNumberedInTurn() { using SqliteLogStore store = SqliteLogStore.Open(path); ContestInstance first = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" }); ContestInstance second = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWPX" }); Assert.Equal(1, first.ContestNumber); Assert.Equal(2, second.ContestNumber); Assert.Equal(2, store.Contests().Count); } [Fact] public void ContactsBelongToTheirOwnContest() { using SqliteLogStore store = SqliteLogStore.Open(path); store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 1)); store.Add(Contact("DL2ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 2)); Assert.Equal("DL1ABC", store.Qsos(1).Single().Call.Text); Assert.Equal("DL2ABC", store.Qsos(2).Single().Call.Text); } [Fact] public void NewDatabaseCarriesN1mmTablesAndVersion() { using (SqliteLogStore store = SqliteLogStore.Open(path)) { } using SqliteConnection connection = new($"Data Source={path}"); connection.Open(); using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"; using SqliteDataReader row = command.ExecuteReader(); List tables = []; while (row.Read()) { tables.Add(row.GetString(0)); } Assert.Contains("DXLOG", tables); Assert.Contains("ContestInstance", tables); Assert.Contains("Contest", tables); Assert.Contains("Station", tables); using SqliteCommand version = connection.CreateCommand(); version.CommandText = "PRAGMA user_version"; Assert.Equal(4L, version.ExecuteScalar()); } [Fact] public void ContactIdCannotBeChanged() { using SqliteLogStore store = SqliteLogStore.Open(path); store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc))); using SqliteConnection connection = new($"Data Source={path}"); connection.Open(); using SqliteCommand command = connection.CreateCommand(); command.CommandText = "UPDATE DXLOG SET ID = 'zzzz'"; Assert.Throws(() => command.ExecuteNonQuery()); } /// N1MM matches a contact to its contest on ContestNR. ContestID is only the /// table's key, and in a log N1MM has been using for a while the two have /// drifted apart, so keying on ContestID hands back another contest's log. [Fact] public void AContestIsFoundByContestNrNotByTheTableKey() { using (SqliteLogStore store = SqliteLogStore.Open(path)) { Execute(""" INSERT INTO ContestInstance (ContestID, ContestName, ContestNR, ModeCategory) VALUES (9, 'CQWWSSB', 7, 'SSB') """); store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), 7)); store.Add(Contact("JA1XYZ", new DateTime(2026, 5, 30, 12, 1, 0, DateTimeKind.Utc), 9)); } using SqliteLogStore reopened = SqliteLogStore.Open(path); ContestInstance? found = reopened.Contest(7); Assert.Equal("CQWWSSB", found?.ContestName); Assert.Equal(7, found?.ContestNumber); Assert.Equal(["DL1ABC"], reopened.Qsos(7).Select(q => q.Call.Text)); Assert.Null(reopened.Contest(9)); } [Fact] public void ANewContestGetsANumberOfItsOwn() { using SqliteLogStore store = SqliteLogStore.Open(path); Execute(""" INSERT INTO ContestInstance (ContestID, ContestName, ContestNR, ModeCategory) VALUES (9, 'CQWWSSB', 7, 'SSB') """); ContestInstance added = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWWCW", }); Assert.Equal(8, added.ContestNumber); Assert.Equal("CQWWCW", store.Contest(8)?.ContestName); } private void Execute(string sql) { using SqliteConnection connection = new($"Data Source={path}"); connection.Open(); using SqliteCommand command = connection.CreateCommand(); command.CommandText = sql; command.ExecuteNonQuery(); } }