mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-14 11:48:28 -04:00
f309ea93f9
# Description of Changes Removes the `TimestampCapabilities` test from the C# SDK's regression test suite, due to creating inconsistent failures in automated CI testing. # API and ABI breaking changes Not API/ABI breaking # Expected complexity level and risk 1 # Testing - [X] Ran locally, no failures in building or running C# regression tests
452 lines
17 KiB
C#
452 lines
17 KiB
C#
/// Regression tests run with a live server.
|
|
/// To run these, run a local SpacetimeDB via `spacetime start`,
|
|
/// then in a separate terminal run `tools~/run-regression-tests.sh PATH_TO_SPACETIMEDB_REPO_CHECKOUT`.
|
|
/// This is done on CI in .github/workflows/test.yml.
|
|
using System;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using SpacetimeDB;
|
|
using SpacetimeDB.Types;
|
|
|
|
const string HOST = "http://localhost:3000";
|
|
const string DBNAME = "btree-repro";
|
|
|
|
DbConnection ConnectToDB()
|
|
{
|
|
DbConnection? conn = null;
|
|
conn = DbConnection
|
|
.Builder()
|
|
.WithUri(HOST)
|
|
.WithModuleName(DBNAME)
|
|
.OnConnect(OnConnected)
|
|
.OnConnectError(
|
|
(err) =>
|
|
{
|
|
throw err;
|
|
}
|
|
)
|
|
.OnDisconnect(
|
|
(conn, err) =>
|
|
{
|
|
if (err != null)
|
|
{
|
|
throw err;
|
|
}
|
|
else
|
|
{
|
|
throw new Exception("Unexpected disconnect");
|
|
}
|
|
}
|
|
)
|
|
.Build();
|
|
return conn;
|
|
}
|
|
|
|
uint waiting = 0;
|
|
var applied = false;
|
|
SubscriptionHandle? handle = null;
|
|
|
|
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
|
{
|
|
Log.Debug($"Connected to {DBNAME} on {HOST}");
|
|
handle = conn.SubscriptionBuilder()
|
|
.OnApplied(OnSubscriptionApplied)
|
|
.OnError(
|
|
(ctx, err) =>
|
|
{
|
|
throw err;
|
|
}
|
|
)
|
|
.Subscribe([
|
|
"SELECT * FROM example_data",
|
|
"SELECT * FROM my_player",
|
|
"SELECT * FROM players_at_level_one",
|
|
"SELECT * FROM my_table",
|
|
]);
|
|
|
|
// If testing against Rust, the indexed parameter will need to be changed to: ulong indexed
|
|
conn.Reducers.OnAdd += (ReducerEventContext ctx, uint id, uint indexed) =>
|
|
{
|
|
Log.Info("Got Add callback");
|
|
waiting--;
|
|
ValidateBTreeIndexes(ctx);
|
|
};
|
|
|
|
conn.Reducers.OnDelete += (ReducerEventContext ctx, uint id) =>
|
|
{
|
|
Log.Info("Got Delete callback");
|
|
waiting--;
|
|
ValidateBTreeIndexes(ctx);
|
|
};
|
|
|
|
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception exception) =>
|
|
{
|
|
Log.Info($"Got OnUnhandledReducerError: {exception}");
|
|
waiting--;
|
|
ValidateBTreeIndexes(ctx);
|
|
};
|
|
}
|
|
|
|
const uint MAX_ID = 10;
|
|
|
|
// Test that indexes remain in sync with the expected table state when deletes are received.
|
|
// This used to fail, when row types did not correctly implement IEquatable.
|
|
void ValidateBTreeIndexes(IRemoteDbContext conn)
|
|
{
|
|
Log.Debug("Checking indexes...");
|
|
foreach (var data in conn.Db.ExampleData.Iter())
|
|
{
|
|
Debug.Assert(conn.Db.ExampleData.Indexed.Filter(data.Id).Contains(data));
|
|
}
|
|
var outOfIndex = conn.Db.ExampleData.Iter().ToHashSet();
|
|
|
|
for (uint i = 0; i < MAX_ID; i++)
|
|
{
|
|
foreach (var data in conn.Db.ExampleData.Indexed.Filter(i))
|
|
{
|
|
Debug.Assert(outOfIndex.Contains(data));
|
|
}
|
|
}
|
|
Log.Debug(" Indexes are good.");
|
|
}
|
|
|
|
void OnSubscriptionApplied(SubscriptionEventContext context)
|
|
{
|
|
applied = true;
|
|
|
|
// Do some operations that alter row state;
|
|
// we will check that everything is in sync in the callbacks for these reducer calls.
|
|
Log.Debug("Calling Add");
|
|
waiting++;
|
|
context.Reducers.Add(1, 1);
|
|
|
|
Log.Debug("Calling Delete");
|
|
waiting++;
|
|
context.Reducers.Delete(1);
|
|
|
|
Log.Debug("Calling Add");
|
|
waiting++;
|
|
context.Reducers.Add(1, 1);
|
|
|
|
Log.Debug("Calling ThrowError");
|
|
waiting++;
|
|
context.Reducers.ThrowError("this is an error");
|
|
|
|
// RemoteQuery test
|
|
Log.Debug("Calling RemoteQuery");
|
|
// If testing against Rust, the query will need to be changed to "WHERE id = 0"
|
|
var remoteRows = context.Db.ExampleData.RemoteQuery("WHERE Id = 1").Result;
|
|
Debug.Assert(remoteRows != null && remoteRows.Length > 0);
|
|
|
|
// Views test
|
|
Log.Debug("Checking Views are populated");
|
|
Debug.Assert(context.Db.MyPlayer != null, "context.Db.MyPlayer != null");
|
|
Debug.Assert(context.Db.PlayersAtLevelOne != null, "context.Db.PlayersAtLevelOne != null");
|
|
Debug.Assert(
|
|
context.Db.MyPlayer.Count > 0,
|
|
$"context.Db.MyPlayer.Count = {context.Db.MyPlayer.Count}"
|
|
);
|
|
Debug.Assert(
|
|
context.Db.PlayersAtLevelOne.Count > 0,
|
|
$"context.Db.PlayersAtLevelOne.Count = {context.Db.PlayersAtLevelOne.Count}"
|
|
);
|
|
|
|
Log.Debug("Calling Iter on View");
|
|
var viewIterRows = context.Db.MyPlayer.Iter();
|
|
var expectedPlayer = new Player
|
|
{
|
|
Id = 1,
|
|
Identity = context.Identity!.Value,
|
|
Name = "NewPlayer",
|
|
};
|
|
Log.Debug(
|
|
"MyPlayer Iter count: " + (viewIterRows != null ? viewIterRows.Count().ToString() : "null")
|
|
);
|
|
Debug.Assert(viewIterRows != null && viewIterRows.Any());
|
|
Log.Debug(
|
|
"Validating View row data "
|
|
+ $"Id={expectedPlayer.Id}, Identity={expectedPlayer.Identity}, Name={expectedPlayer.Name} => "
|
|
+ $"Id={viewIterRows.First().Id}, Identity={viewIterRows.First().Identity}, Name={viewIterRows.First().Name}"
|
|
);
|
|
Debug.Assert(viewIterRows.First().Equals(expectedPlayer));
|
|
|
|
Log.Debug("Calling RemoteQuery on View");
|
|
// If testing against Rust, the query will need to be changed to "WHERE id > 0"
|
|
var viewRemoteQueryRows = context.Db.MyPlayer.RemoteQuery("WHERE Id > 0");
|
|
Debug.Assert(viewRemoteQueryRows != null && viewRemoteQueryRows.Result.Length > 0);
|
|
Debug.Assert(viewRemoteQueryRows.Result.First().Equals(expectedPlayer));
|
|
|
|
Log.Debug("Calling Iter on Anonymous View");
|
|
var anonViewIterRows = context.Db.PlayersAtLevelOne.Iter();
|
|
var expectedPlayerAndLevel = new PlayerAndLevel
|
|
{
|
|
Id = 1,
|
|
Identity = context.Identity!.Value,
|
|
Name = "NewPlayer",
|
|
Level = 1,
|
|
};
|
|
Log.Debug(
|
|
"PlayersAtLevelOne Iter count: "
|
|
+ (anonViewIterRows != null ? anonViewIterRows.Count().ToString() : "null")
|
|
);
|
|
Debug.Assert(anonViewIterRows != null && anonViewIterRows.Any());
|
|
Log.Debug(
|
|
"Validating Anonymous View row data "
|
|
+ $"Id={expectedPlayerAndLevel.Id}, Identity={expectedPlayerAndLevel.Identity}, Name={expectedPlayerAndLevel.Name}, Level={expectedPlayerAndLevel.Level} => "
|
|
+ $"Id={anonViewIterRows.First().Id}, Identity={anonViewIterRows.First().Identity}, Name={anonViewIterRows.First().Name}, Level={anonViewIterRows.First().Level} => "
|
|
);
|
|
Debug.Assert(anonViewIterRows.First().Equals(expectedPlayerAndLevel));
|
|
|
|
Log.Debug("Calling RemoteQuery on Anonymous View");
|
|
// If testing against Rust, the query will need to be changed to "WHERE level = 1"
|
|
var anonViewRemoteQueryRows = context.Db.PlayersAtLevelOne.RemoteQuery("WHERE Level = 1");
|
|
Log.Debug(
|
|
"PlayersAtLevelOne RemoteQuery count: "
|
|
+ (
|
|
anonViewRemoteQueryRows != null
|
|
? anonViewRemoteQueryRows.Result.Length.ToString()
|
|
: "null"
|
|
)
|
|
);
|
|
Debug.Assert(anonViewRemoteQueryRows != null && anonViewRemoteQueryRows.Result.Length > 0);
|
|
Debug.Assert(anonViewRemoteQueryRows.Result.First().Equals(expectedPlayerAndLevel));
|
|
|
|
// Procedures tests
|
|
Log.Debug("Calling InsertWithTxRollback");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxRollback((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
if (result.IsSuccess)
|
|
{
|
|
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after rollback. Count was {context.Db.MyTable.Count}");
|
|
Log.Debug("Insert with transaction rollback succeeded");
|
|
}
|
|
else
|
|
{
|
|
throw new Exception("Expected InsertWithTransactionRollback to fail, but it succeeded");
|
|
}
|
|
waiting--;
|
|
});
|
|
|
|
Log.Debug("Calling InsertWithTxPanic");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxPanic((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"InsertWithTxPanic should succeed (exception is caught). Error received: {result.Error}");
|
|
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after exception abort. Count was {context.Db.MyTable.Count}");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling DanglingTxWarning");
|
|
waiting++;
|
|
context.Procedures.DanglingTxWarning((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"DanglingTxWarning should succeed. Error received: {result.Error}");
|
|
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after dangling tx auto-abort. Count was {context.Db.MyTable.Count}");
|
|
// Note: We can't easily assert on the warning log from client-side,
|
|
// but the server-side AssertRowCount verifies the auto-abort behavior
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling InsertWithTxCommit");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxCommit((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"InsertWithTxCommit should succeed. Error received: {result.Error}");
|
|
var expectedRow = new MyTable(new ReturnStruct(42, "magic"));
|
|
var row = context.Db.MyTable.Iter().FirstOrDefault();
|
|
Debug.Assert(row != null);
|
|
Debug.Assert(row.Equals(expectedRow));
|
|
Log.Debug("Insert with transaction commit succeeded");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling InsertWithTxRetry");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxRetry((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"InsertWithTxRetry should succeed after retry. Error received: {result.Error}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Exception(ex);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling TxContextCapabilities");
|
|
waiting++;
|
|
context.Procedures.TxContextCapabilities((IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"TxContextCapabilities should succeed. Error received: {result.Error}");
|
|
Debug.Assert(result.Value != null && result.Value.B.StartsWith("sender:"), $"Expected sender info, got {result.Value.B}");
|
|
|
|
// Verify the inserted row has the expected data
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
var timestampRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("tx-test"));
|
|
Debug.Assert(timestampRow != null && timestampRow.Field.A == 200, $"Expected field.A == 200, got {timestampRow.Field.A}");
|
|
Debug.Assert(timestampRow.Field.B == "tx-test", $"Expected field.B == 'tx-test', got {timestampRow.Field.B}");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling AuthenticationCapabilities");
|
|
waiting++;
|
|
context.Procedures.AuthenticationCapabilities((IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"AuthenticationCapabilities should succeed. Error received: {result.Error}");
|
|
Debug.Assert(result.Value != null, "Should return a valid sender-derived value");
|
|
Debug.Assert(result.Value.B.Contains("jwt:") || result.Value.B == "no-jwt", $"Should return JWT info, got {result.Value.B}");
|
|
|
|
// Verify the inserted row has authentication information
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
|
|
var authRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("auth:"));
|
|
Debug.Assert(authRow is not null, "Should have a row with auth data");
|
|
Debug.Assert(authRow.Field.B.Contains("sender:"), "Auth row should contain sender info");
|
|
Debug.Assert(authRow.Field.B.Contains("conn:"), "Auth row should contain connection info");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling SubscriptionEventOffset");
|
|
waiting++;
|
|
context.Procedures.SubscriptionEventOffset((IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, $"SubscriptionEventOffset should succeed. Error received: {result.Error}");
|
|
Debug.Assert(result.Value != null && result.Value.A == 999, $"Expected A == 999, got {result.Value.A}");
|
|
Debug.Assert(result.Value.B.StartsWith("committed:"), $"Expected committed timestamp, got {result.Value.B}");
|
|
|
|
// Verify the inserted row has the expected offset test data
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
var offsetRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("offset-test:"));
|
|
Debug.Assert(offsetRow is not null, "Should have a row with offset-test data");
|
|
Debug.Assert(offsetRow.Field.A == 999, "Offset test row should have A == 999");
|
|
|
|
// Note: Transaction offset information is not directly accessible in ProcedureEvent,
|
|
// but this test verifies that the transaction was committed and subscription events were generated
|
|
// The presence of the new row in the subscription confirms the transaction offset was processed
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
Log.Debug("Calling DocumentationGapChecks with valid parameters");
|
|
waiting++;
|
|
context.Procedures.DocumentationGapChecks(42, "test-input", (IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(result.IsSuccess, "DocumentationGapChecks should succeed with valid parameters");
|
|
|
|
// Expected: inputValue * 2 + inputText.Length = 42 * 2 + 10 = 94
|
|
var expectedValue = 42u * 2 + (uint)"test-input".Length; // 84 + 10 = 94
|
|
Debug.Assert(result.Value != null && result.Value.A == expectedValue, $"Expected A == {expectedValue}, got {result.Value.A}");
|
|
Debug.Assert(result.Value.B.StartsWith("success:"), $"Expected success message, got {result.Value.B}");
|
|
Debug.Assert(result.Value.B.Contains("test-input"), "Result should contain input text");
|
|
|
|
// Verify the inserted row has the expected documentation gap test data
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
var docGapRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("doc-gap:"));
|
|
Debug.Assert(docGapRow is not null, "Should have a row with doc-gap data");
|
|
Debug.Assert(docGapRow.Field.A == expectedValue, $"Doc gap row should have A == {expectedValue}");
|
|
Debug.Assert(docGapRow.Field.B.Contains("test-input"), "Doc gap row should contain input text");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
// Test error handling with invalid parameters
|
|
Log.Debug("Calling DocumentationGapChecks with invalid parameters (should fail)");
|
|
waiting++;
|
|
context.Procedures.DocumentationGapChecks(0, "", (IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(!result.IsSuccess, "DocumentationGapChecks should fail with invalid parameters");
|
|
// TODO: Testing against Rust, this returned a different error type "System.Exception". Decide if this is a bug or not.
|
|
//Debug.Assert(result.Error is ArgumentException, $"Expected ArgumentException, got {result.Error?.GetType()}");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
});
|
|
|
|
// Now unsubscribe and check that the unsubscribing is actually applied.
|
|
Log.Debug("Calling Unsubscribe");
|
|
waiting++;
|
|
handle?.UnsubscribeThen(
|
|
(ctx) =>
|
|
{
|
|
Log.Debug("Received Unsubscribe");
|
|
ValidateBTreeIndexes(ctx);
|
|
waiting--;
|
|
}
|
|
);
|
|
}
|
|
|
|
System.AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
|
|
{
|
|
Log.Exception($"Unhandled exception: {sender} {args}");
|
|
Environment.Exit(1);
|
|
};
|
|
var db = ConnectToDB();
|
|
Log.Info("Starting timer");
|
|
const int TIMEOUT = 20; // seconds;
|
|
var start = DateTime.Now;
|
|
while (!applied || waiting > 0)
|
|
{
|
|
db.FrameTick();
|
|
Thread.Sleep(100);
|
|
if ((DateTime.Now - start).Seconds > TIMEOUT)
|
|
{
|
|
Log.Error($"Timeout, all events should have elapsed in {TIMEOUT} seconds!");
|
|
Environment.Exit(1);
|
|
}
|
|
}
|
|
Log.Info("Success");
|
|
Environment.Exit(0);
|