refactor(learning): split answer idempotency registry
This commit is contained in:
@@ -102,6 +102,18 @@ public sealed class CurrentAnswer : TenantEntity
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AnswerSubmissionIdempotency : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid PracticeSessionId { get; set; }
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
public string RequestHash { get; set; } = string.Empty;
|
||||
public Guid AnswerRecordId { get; set; }
|
||||
public JsonElement ResponseSnapshot { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public enum AnswerGradingStatus
|
||||
{
|
||||
PendingReview,
|
||||
|
||||
@@ -21,6 +21,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
throw new LearningValidationException("selected_option_index_invalid",
|
||||
"Selected option indices must be zero-based non-negative values.");
|
||||
|
||||
var idempotencyKey = command.IdempotencyKey.Trim();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionQuestion = await learningPersistence.PracticeSessionQuestions.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken);
|
||||
@@ -40,18 +41,20 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
await learningAccessService.EnsureStrongRevocationVersionAsync(
|
||||
actor, session.StrongRevocationVersion, cancellationToken);
|
||||
var deliveryVersion = await LoadDeliveryVersionAsync(actor.TenantId, sessionQuestion, cancellationToken);
|
||||
var revealedSolution = DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion);
|
||||
|
||||
var requestHash = HashAnswer(command);
|
||||
var existingAnswer = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(
|
||||
var existingSubmission = await learningPersistence.AnswerSubmissionIdempotencies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.IdempotencyKey == command.IdempotencyKey,
|
||||
item.IdempotencyKey == idempotencyKey,
|
||||
cancellationToken);
|
||||
if (existingAnswer is not null)
|
||||
if (existingSubmission is not null)
|
||||
{
|
||||
if (!string.Equals(existingAnswer.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
if (!string.Equals(existingSubmission.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
AnswerConflicts.Add(1);
|
||||
throw new LearningValidationException("idempotency_conflict",
|
||||
@@ -59,9 +62,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
}
|
||||
|
||||
IdempotencyReplays.Add(1);
|
||||
return ToItem(
|
||||
existingAnswer,
|
||||
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
|
||||
return await ReplayAsync(existingSubmission, revealedSolution, cancellationToken);
|
||||
}
|
||||
|
||||
if (session.ExpiresAt.HasValue && session.ExpiresAt <= now)
|
||||
@@ -120,7 +121,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
AwardedScore = grading.AwardedScore,
|
||||
Revision = (current?.Revision ?? 0) + 1,
|
||||
ClientSequence = command.ClientSequence,
|
||||
IdempotencyKey = command.IdempotencyKey.Trim(),
|
||||
IdempotencyKey = idempotencyKey,
|
||||
RequestHash = requestHash,
|
||||
AnsweredAt = now,
|
||||
CreatedAt = now
|
||||
@@ -149,9 +150,18 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
current.Version++;
|
||||
current.UpdatedAt = now;
|
||||
}
|
||||
var response = ToItem(
|
||||
record,
|
||||
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
|
||||
var response = ToItem(record, revealedSolution);
|
||||
learningPersistence.AnswerSubmissionIdempotencies.Add(new AnswerSubmissionIdempotency
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PracticeSessionId = session.Id,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
RequestHash = requestHash,
|
||||
AnswerRecordId = record.Id,
|
||||
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
|
||||
CreatedAt = now
|
||||
});
|
||||
try
|
||||
{
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
@@ -166,17 +176,16 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
postgresException.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
unitOfWork.ChangeTracker.Clear();
|
||||
var replay = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
var replay = await learningPersistence.AnswerSubmissionIdempotencies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.IdempotencyKey == idempotencyKey, cancellationToken);
|
||||
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
IdempotencyReplays.Add(1);
|
||||
return ToItem(
|
||||
replay,
|
||||
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
|
||||
return await ReplayAsync(replay, revealedSolution, cancellationToken);
|
||||
}
|
||||
|
||||
AnswerConflicts.Add(1);
|
||||
@@ -186,4 +195,23 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<AnswerRecordItem> ReplayAsync(
|
||||
AnswerSubmissionIdempotency submission,
|
||||
QuestionSolutionItem? revealedSolution,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (submission.ResponseSnapshot.ValueKind == JsonValueKind.Object &&
|
||||
(submission.ResponseSnapshot.TryGetProperty(nameof(AnswerRecordItem.Id), out _) ||
|
||||
submission.ResponseSnapshot.TryGetProperty("id", out _)))
|
||||
return submission.ResponseSnapshot.Deserialize<AnswerRecordItem>()
|
||||
?? throw new InvalidOperationException("The stored answer response is invalid.");
|
||||
|
||||
var historicalRecord = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == submission.TenantId && item.Id == submission.AnswerRecordId,
|
||||
cancellationToken);
|
||||
return historicalRecord is null
|
||||
? throw new InvalidOperationException("The idempotent answer record no longer exists.")
|
||||
: ToItem(historicalRecord, revealedSolution);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,13 +134,6 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
entity.ClientSequence
|
||||
}).IsUnique();
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.IdempotencyKey
|
||||
}).IsUnique().HasDatabaseName("ux_answer_records_idempotency");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
@@ -183,6 +176,45 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AnswerSubmissionIdempotencyConfiguration : IEntityTypeConfiguration<AnswerSubmissionIdempotency>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AnswerSubmissionIdempotency> builder)
|
||||
{
|
||||
builder.ConfigureEntity("answer_submission_idempotencies");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(entity => entity.RequestHash).HasMaxLength(64);
|
||||
builder.Property(entity => entity.ResponseSnapshot).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.IdempotencyKey
|
||||
}).IsUnique().HasDatabaseName("ux_answer_submission_idempotency");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.AnswerRecordId });
|
||||
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<PracticeSession>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId
|
||||
})
|
||||
.HasPrincipalKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.Id
|
||||
})
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CurrentAnswerConfiguration : IEntityTypeConfiguration<CurrentAnswer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CurrentAnswer> builder)
|
||||
|
||||
22589
Tiku.Infrastructure/Persistence/Migrations/20260805062851_SplitAnswerSubmissionIdempotency.Designer.cs
generated
Normal file
22589
Tiku.Infrastructure/Persistence/Migrations/20260805062851_SplitAnswerSubmissionIdempotency.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SplitAnswerSubmissionIdempotency : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "answer_submission_idempotencies",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
practice_session_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
request_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
answer_record_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
response_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_answer_submission_idempotencies", x => x.id);
|
||||
table.UniqueConstraint("ak_answer_submission_idempotencies_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_answer_submission_idempotencies_practice_sessions_tenant_id~",
|
||||
columns: x => new { x.tenant_id, x.user_id, x.practice_session_id },
|
||||
principalTable: "practice_sessions",
|
||||
principalColumns: new[] { "tenant_id", "user_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_answer_submission_idempotencies_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_answer_submission_idempotencies_tenant_id_answer_record_id",
|
||||
table: "answer_submission_idempotencies",
|
||||
columns: new[] { "tenant_id", "answer_record_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_answer_submission_idempotencies_user_id",
|
||||
table: "answer_submission_idempotencies",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_answer_submission_idempotency",
|
||||
table: "answer_submission_idempotencies",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "idempotency_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO answer_submission_idempotencies
|
||||
(id, tenant_id, user_id, practice_session_id, idempotency_key, request_hash,
|
||||
answer_record_id, response_snapshot, created_at)
|
||||
SELECT gen_random_uuid(), tenant_id, user_id, practice_session_id, idempotency_key,
|
||||
request_hash, id, '{}'::jsonb, created_at
|
||||
FROM answer_records
|
||||
WHERE idempotency_key <> ''
|
||||
ON CONFLICT (tenant_id, user_id, practice_session_id, idempotency_key) DO NOTHING
|
||||
""");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_answer_records_idempotency",
|
||||
table: "answer_records");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "answer_submission_idempotencies");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_answer_records_idempotency",
|
||||
table: "answer_records",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "idempotency_key" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8762,10 +8762,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_answer_records_tenant_id_user_id_practice_session_id_client~");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ux_answer_records_idempotency");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "SessionQuestionId", "Revision")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ux_answer_records_session_question_revision");
|
||||
@@ -8773,6 +8769,73 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("answer_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.AnswerSubmissionIdempotency", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid>("AnswerRecordId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("answer_record_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<Guid>("PracticeSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("practice_session_id");
|
||||
|
||||
b.Property<string>("RequestHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("request_hash");
|
||||
|
||||
b.Property<JsonElement>("ResponseSnapshot")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("response_snapshot")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_answer_submission_idempotencies");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_answer_submission_idempotencies_tenant_id_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_answer_submission_idempotencies_user_id");
|
||||
|
||||
b.HasIndex("TenantId", "AnswerRecordId")
|
||||
.HasDatabaseName("ix_answer_submission_idempotencies_tenant_id_answer_record_id");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ux_answer_submission_idempotency");
|
||||
|
||||
b.ToTable("answer_submission_idempotencies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.BusinessLine", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -20478,6 +20541,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_answer_records_practice_sessions_tenant_id_user_id_practice~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.AnswerSubmissionIdempotency", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_answer_submission_idempotencies_users_user_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Learning.PracticeSession", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "UserId", "PracticeSessionId")
|
||||
.HasPrincipalKey("TenantId", "UserId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_answer_submission_idempotencies_practice_sessions_tenant_id~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.ClassContentAssignment", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
|
||||
@@ -145,6 +145,7 @@ public interface ILearningPersistence : IModulePersistence
|
||||
DbSet<PracticeSessionQuestion> PracticeSessionQuestions { get; }
|
||||
DbSet<AnswerRecord> AnswerRecords { get; }
|
||||
DbSet<CurrentAnswer> CurrentAnswers { get; }
|
||||
DbSet<AnswerSubmissionIdempotency> AnswerSubmissionIdempotencies { get; }
|
||||
DbSet<LearningOperationIdempotency> LearningOperationIdempotencies { get; }
|
||||
DbSet<LearningOutboxMessage> LearningOutboxMessages { get; }
|
||||
DbSet<LearningProjectionReceipt> LearningProjectionReceipts { get; }
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed partial class TikuDbContext
|
||||
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
|
||||
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||
public DbSet<CurrentAnswer> CurrentAnswers => Set<CurrentAnswer>();
|
||||
public DbSet<AnswerSubmissionIdempotency> AnswerSubmissionIdempotencies => Set<AnswerSubmissionIdempotency>();
|
||||
public DbSet<LearningOperationIdempotency> LearningOperationIdempotencies => Set<LearningOperationIdempotency>();
|
||||
public DbSet<LearningOutboxMessage> LearningOutboxMessages => Set<LearningOutboxMessage>();
|
||||
public DbSet<LearningProjectionReceipt> LearningProjectionReceipts => Set<LearningProjectionReceipt>();
|
||||
|
||||
@@ -559,7 +559,15 @@ public sealed class LearningEndpointTests
|
||||
};
|
||||
|
||||
var first = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest);
|
||||
var replay = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest);
|
||||
var replay = await client.PostAsJsonAsync(
|
||||
"/api/student/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = " revision-answer-1 ",
|
||||
SelectedOptionIndices = [1]
|
||||
});
|
||||
var conflict = await client.PostAsJsonAsync(
|
||||
"/api/student/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
@@ -604,6 +612,8 @@ public sealed class LearningEndpointTests
|
||||
.OrderBy(item => item.Revision)
|
||||
.ToArrayAsync();
|
||||
Assert.Equal(2, records.Length);
|
||||
Assert.Equal(2, await db.AnswerSubmissionIdempotencies.CountAsync(item =>
|
||||
item.PracticeSessionId == answerable.SessionId));
|
||||
var current = await db.CurrentAnswers.SingleAsync(item =>
|
||||
item.PracticeSessionId == answerable.SessionId &&
|
||||
item.SessionQuestionId == answerable.SessionQuestionId);
|
||||
|
||||
Reference in New Issue
Block a user