test(wizard): add submit, cleanup, router delegation tests

This commit is contained in:
2026-06-04 10:33:50 +03:00
parent 2819786f91
commit 186492a18d
9 changed files with 549 additions and 6 deletions
@@ -0,0 +1,79 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that <see cref="CreateSessionHandler.SubmitDraftAsync"/> bails
/// out gracefully when the wizard payload is missing required fields. The
/// missing-fields path returns before the shared handler is ever called,
/// so we pass <c>null!</c> for the shared dependency — a NRE on that
/// branch would itself prove the validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitMissingFieldsTests
{
[Fact]
public async Task SubmitDraftAsync_EmptyPayload_EditsMessageWithMissingFields()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!, // missing-fields path returns before touching the shared handler
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Empty payload → every required field is missing.
var draft = NewDraft(WizardStepNames.Confirm, new WizardPayload());
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
// The wizard message is edited to surface the missing-field error.
Assert.Single(messenger.Edits);
var edit = messenger.Edits[0];
Assert.Equal(draft.ChatId, edit.ChatId);
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingTitleOnly_EditsMessageNamingTitle()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Title.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("название", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a pool wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitPoolDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompletePoolPayload_CreatesBatchOfSessions() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a single-game wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitSingleDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,150 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the validation gates inside
/// <see cref="CreateSessionHandler.SubmitDraftAsync"/>. We never reach the
/// shared handler in any of these tests, so the shared dependency is
/// passed as <c>null!</c> — a NRE on that branch would itself prove the
/// validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitValidationTests
{
[Fact]
public async Task SubmitDraftAsync_MissingVisibility_EditsMessageNamingVisibility()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Visibility.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("видимость", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingSystem_EditsMessageNamingSystem()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except System.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("система", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingDateTimeForSingleType_EditsMessageNamingDateTime()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except ScheduledAt for Single type.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput { MaxPlayers = 4 },
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("дата/время", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_EmptyPool_EditsMessageNamingSlots()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Pool type with no slots at all.
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "P",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput(),
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,119 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Types;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that the <see cref="UpdateRouter"/> delegates to the wizard when
/// the GM has an active (non-expired) draft, and falls through to normal
/// handling when no draft is active. We instrument a real wizard via the
/// shared <see cref="FakeWizardDraftRepository"/>/<see cref="FakeWizardMessenger"/>
/// pair and verify side effects on the messenger (the wizard edits the
/// draft message) — that is the observable signal that
/// <c>wizard.HandleUpdateAsync</c> was called.
/// </summary>
public sealed class UpdateRouterDelegationTests
{
[Fact]
public async Task ActiveDraft_Existing_RoutesToWizard()
{
var sut = BuildRouter(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId);
await sut.RouteAsync(update, CancellationToken.None);
// Wizard edits the draft message when it processes a title.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task ActiveDraft_Existing_OnCallback_AlsoRoutesToWizard()
{
var sut = BuildRouter(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
// "wizard:cancel" — wizard owns the cancel callback. The router
// delegates control-callbacks (resume/reset) but lets the wizard
// handle wizard:* callbacks.
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId);
await sut.RouteAsync(update, CancellationToken.None);
// Cancel deletes the draft via the wizard.
Assert.Contains(draft.Id, drafts.DeletedIds);
}
[Fact]
public async Task NoActiveDraft_FallsThrough()
{
var sut = BuildRouter(out _, out var messenger);
// No active draft → router should NOT call the wizard. It will
// attempt to run the /help command via the fallback command path.
// We send a /help message; the router has no draft to act on.
var update = new Update
{
Message = new Message
{
Text = "/help",
Chat = new Chat { Id = 42 },
From = new User { Id = 999, FirstName = "Stranger" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The wizard should not have edited anything (no draft was active).
Assert.Empty(messenger.Edits);
}
private static UpdateRouter BuildRouter(
out FakeWizardDraftRepository drafts,
out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
// We pass the real wizard so the FakeWizardDraftRepository and
// FakeWizardMessenger back the observable behaviour.
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// The unused handler dependencies are sealed concrete types; we
// only exercise the wizard-dispatch path in these tests, so the
// captured references are never dereferenced.
var router = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: null!,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: Substitute.For<ITelegramBotClient>(),
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return router;
}
}
@@ -0,0 +1,96 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// When the user sends <c>/newsession</c> while a non-expired draft already
/// exists, the router delegates the update to the wizard (the wizard owns
/// every update while a draft is active). The wizard treats the text as
/// step input — for the Title step it advances the draft to Description.
/// This is the observable contract that this test pins down.
/// </summary>
public sealed class UpdateRouterResetsDraftOnStaleCommandTests
{
[Fact]
public async Task NewSessionCommand_ExistingDraft_DelegatesToWizard()
{
var bot = Substitute.For<ITelegramBotClient>();
var (sut, drafts, messenger) = BuildRouter(bot);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = new Update
{
Message = new Message
{
Text = "/newsession",
Chat = new Chat { Id = draft.ChatId },
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The router delegates to the wizard, which edits the draft
// message as the Title step accepts the input and advances to
// Description. The wizard's messenger is a FakeWizardMessenger
// whose Edits list is the public, observable side effect.
Assert.NotEmpty(messenger.Edits);
// The bot.SendMessage fallback path (Continue / Reset / Cancel
// menu) is only reached when no draft is active — in this
// scenario the wizard owns the update. We assert it was NOT
// taken here.
await bot.DidNotReceiveWithAnyArgs().SendRequest(default(IRequest<Message>)!, default);
}
private static (UpdateRouter sut, FakeWizardDraftRepository drafts, FakeWizardMessenger messenger) BuildRouter(
ITelegramBotClient bot)
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// Real Bot-side CreateSessionHandler — the test relies on
// StartWizardAsync returning null when an active draft exists.
// We pass null! for the shared handler since the active-draft
// path never touches it.
var createSessionHandler = new BotCreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<BotCreateSessionHandler>.Instance);
var sut = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: createSessionHandler,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: bot,
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return (sut, drafts, messenger);
}
}
@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SharedDraft = GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the cleanup background service: each tick should call the draft
/// repository to delete expired drafts and must not propagate repository
/// failures (a transient DB blip should not bring the worker down).
/// </summary>
public sealed class WizardDraftCleanupServiceTests
{
[Fact]
public async Task RunOnceAsync_DeletesExpiredDrafts()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>()).Returns(7);
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task RunOnceAsync_OnRepositoryError_DoesNotThrow()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("boom"));
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
// Should swallow the exception — cleanup is best-effort.
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
}
@@ -15,6 +15,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" /> <PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
+20 -6
View File
@@ -28,6 +28,15 @@
"Microsoft.TestPlatform.TestHost": "17.14.1" "Microsoft.TestPlatform.TestHost": "17.14.1"
} }
}, },
"NSubstitute": {
"type": "Direct",
"requested": "[5.3.0, )",
"resolved": "5.3.0",
"contentHash": "lJ47Cps5Qzr86N99lcwd+OUvQma7+fBgr8+Mn+aOC0WrlqMNkdivaYD9IvnZ5Mqo6Ky3LS7ZI+tUq1/s9ERd0Q==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"SecurityCodeScan.VS2019": { "SecurityCodeScan.VS2019": {
"type": "Direct", "type": "Direct",
"requested": "[5.6.7, )", "requested": "[5.6.7, )",
@@ -84,6 +93,11 @@
"resolved": "2.6.2", "resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
}, },
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g=="
},
"Dapper": { "Dapper": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.1.72", "resolved": "2.1.72",
@@ -487,8 +501,8 @@
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )", "Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.5.1, )", "GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.5.1, )", "GmRelay.Shared": "[3.7.1, )",
"Npgsql": "[10.0.2, )", "Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )", "Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )" "dbup-postgresql": "[7.0.1, )"
@@ -500,8 +514,8 @@
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )", "Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.5.1, )", "GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.5.1, )", "GmRelay.Shared": "[3.7.1, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )", "NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )", "NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )", "NetCord.Services": "[1.0.0-alpha.489, )",
@@ -532,8 +546,8 @@
"dependencies": { "dependencies": {
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[3.5.1, )", "GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.5.1, )", "GmRelay.Shared": "[3.7.1, )",
"Npgsql": "[10.0.2, )", "Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )" "Telegram.Bot": "[22.9.6.1, )"
} }