Chat stays usable when the file service is off or down. Files up to 256 MB are hosted like chat; downloads try the host first and fall back to file-service tickets. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
798 lines
37 KiB
C#
798 lines
37 KiB
C#
using System.Buffers.Binary;
|
|
using WebDrop.Core;
|
|
|
|
namespace WebDrop.Core.Tests;
|
|
|
|
public class ChannelServiceTests : IDisposable
|
|
{
|
|
private readonly TestEnv _env = new();
|
|
private ChannelService Svc => _env.Service;
|
|
private readonly byte[] _ownerEp = TestEnv.Endpoint();
|
|
private readonly byte[] _memberEp = TestEnv.Endpoint();
|
|
|
|
public void Dispose() => _env.Dispose();
|
|
|
|
// ───── 辅助 ─────
|
|
|
|
private static async Task AssertCode(ErrorCode code, Func<Task> act) =>
|
|
Assert.Equal(code, (await Assert.ThrowsAsync<WebDropException>(act)).Code);
|
|
|
|
private static void AssertCode(ErrorCode code, Action act) =>
|
|
Assert.Equal(code, Assert.Throws<WebDropException>(act).Code);
|
|
|
|
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null) =>
|
|
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads);
|
|
|
|
private Task<AppendResult> AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) =>
|
|
Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill));
|
|
|
|
private static (long Seq, LogKind Kind) Head(LogRecord r) =>
|
|
(BinaryPrimitives.ReadInt64BigEndian(r.Bytes), (LogKind)r.Bytes[8]);
|
|
|
|
private async Task<List<(long Seq, LogKind Kind)>> LogOf(TestChannel ch, Credentials who, long from = 0) =>
|
|
(await Svc.ReadLogAsync(ch.Id, who, from, 1000)).Records.Select(Head).ToList();
|
|
|
|
// ───── 创建 ─────
|
|
|
|
[Fact]
|
|
public void CreateRejectsDuplicateId()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var dup = new CreateChannelRequest
|
|
{
|
|
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
|
|
Settings = new ChannelSettings { Name = "Duplicate" },
|
|
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
|
|
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
|
|
};
|
|
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, "owner"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateEnforcesModeSpecificHashes()
|
|
{
|
|
string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32)));
|
|
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "Raw space" }, ReadHash = h(), OwnerHash = h() };
|
|
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() };
|
|
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } };
|
|
|
|
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, "owner"));
|
|
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, "owner"));
|
|
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, "owner"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateSeatsTheOwnerAndReportsDefaults()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
|
Assert.Equal(1, info.MembersUsed);
|
|
Assert.Equal(2, info.Settings.MaxMembers);
|
|
Assert.Equal(1, info.OldestSeq);
|
|
Assert.Equal(0, info.LastSeq);
|
|
}
|
|
|
|
// ───── 鉴权与席位 ─────
|
|
|
|
[Fact]
|
|
public void WrongTokenIsUnauthorized()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp, "owner")));
|
|
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp, "owner")));
|
|
// 用读令牌冒充所有者
|
|
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp, "owner")));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownOrExpiredChannelIsNotFound()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(TestEnv.NewId(), ch.Owner(_ownerEp)));
|
|
_env.Clock.Advance(TimeSpan.FromHours(25));
|
|
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
|
}
|
|
|
|
[Fact]
|
|
public void JoinIsIdempotentAndEnforcesSeatLimit()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
Assert.Equal(a.JoinedAt, again.JoinedAt);
|
|
Assert.Equal(2, again.MembersUsed);
|
|
Assert.Equal(2, again.MembersMax);
|
|
|
|
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
|
|
Assert.Equal(1, _env.Events.Members); // 只有真正新增时才通知
|
|
}
|
|
|
|
[Fact]
|
|
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
|
|
{
|
|
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 2 });
|
|
var memberOne = TestEnv.Endpoint();
|
|
var memberTwo = TestEnv.Endpoint();
|
|
var ownerTwo = TestEnv.Endpoint();
|
|
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
|
|
|
|
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).MembersUsed);
|
|
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).MembersUsed);
|
|
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).MembersUsed);
|
|
|
|
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
|
|
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, bob));
|
|
|
|
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
|
|
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).MembersUsed);
|
|
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataOperationsRequireASeat()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
|
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void CreateRequiresSpaceName(string? name)
|
|
{
|
|
var request = new CreateChannelRequest
|
|
{
|
|
Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw,
|
|
Settings = new ChannelSettings { Name = name },
|
|
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
|
|
};
|
|
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, "owner"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateTrimsSpaceName()
|
|
{
|
|
var channel = _env.Create(_ownerEp, settings: new ChannelSettings { Name = " Team space " });
|
|
Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FileObjectBytesAreNeverStoredByTheHostAWholeFileIsRegisteredByDigestsInOneStep()
|
|
{
|
|
var space = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
var owner = space.Owner(_ownerEp);
|
|
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
|
|
var item = TestEnv.NewId();
|
|
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
|
|
|
// 频道没有文件域(文件服务没开、小文件):文件和聊天一样存在主后端,不能整个登记
|
|
var hosted = TestEnv.NewId();
|
|
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hosted.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
|
Assert.True((await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(hosted, 0, FrameFlags.Gated | FrameFlags.Final, 60), 3)).Created);
|
|
var hostedFrame = TestEnv.Frame(hosted, 0, FrameFlags.Gated | FrameFlags.Final, 60);
|
|
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, hostedFrame.AsMemory(0, Envelope.HeaderSize),
|
|
[new ChannelService.FrameEntry(60, System.Security.Cryptography.SHA256.HashData(hostedFrame))], 3)).Code);
|
|
|
|
// 分配了文件域的频道:文件域的对象不能把字节直接交给主后端
|
|
Svc.SetWorkspaceChannelFileDomain(space.Id, owner, channelId, "domain-1");
|
|
byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120);
|
|
await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, null));
|
|
|
|
// 整个文件一次登记:末帧头部 + 每一帧的(负载长度,整帧摘要)
|
|
byte[][] frames = [first, TestEnv.Frame(item, 1, FrameFlags.Gated, 80), TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Final, 40)];
|
|
ChannelService.FrameEntry[] entries = frames.Select(f => new ChannelService.FrameEntry(f.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(f))).ToArray();
|
|
var header = frames[^1].AsMemory(0, Envelope.HeaderSize);
|
|
Assert.True(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created);
|
|
Assert.False(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); // 同一个文件重复登记幂等
|
|
var other = entries.ToArray(); other[1] = new ChannelService.FrameEntry(other[1].PayloadLength, new byte[32]);
|
|
Assert.Equal(ErrorCode.Conflict, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突
|
|
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, frames[0].AsMemory(0, Envelope.HeaderSize), entries, 3)).Code); // 头部必须是末帧
|
|
|
|
// 聊天域的对象不能这样登记(它们的字节就存在主后端)
|
|
var chatItem = TestEnv.NewId();
|
|
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = chatItem.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
|
|
var chatFrame = TestEnv.Frame(chatItem, 0, FrameFlags.Final, 40);
|
|
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, chatFrame.AsMemory(0, Envelope.HeaderSize),
|
|
[new ChannelService.FrameEntry(40, System.Security.Cryptography.SHA256.HashData(chatFrame))], null)).Code);
|
|
|
|
Svc.DeleteItem(space.Id, owner, item);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
|
|
{
|
|
var otherOwnerEndpoint = TestEnv.Endpoint();
|
|
var first = _env.Create(_ownerEp);
|
|
var second = _env.Create(otherOwnerEndpoint);
|
|
var sharedItemId = TestEnv.NewId();
|
|
var firstOnlyItemId = TestEnv.NewId();
|
|
await AppendSmall(first, first.Owner(_ownerEp), sharedItemId, fill: 0x11);
|
|
await AppendSmall(second, second.Owner(otherOwnerEndpoint), sharedItemId, fill: 0x22);
|
|
await AppendSmall(first, first.Owner(_ownerEp), firstOnlyItemId, fill: 0x33);
|
|
|
|
var firstFrame = (await Svc.ReadItemFramesAsync(first.Id, first.Owner(_ownerEp), sharedItemId, 0, 10)).Frames.Single();
|
|
var secondFrame = (await Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), sharedItemId, 0, 10)).Frames.Single();
|
|
Assert.Equal(0x11, firstFrame[^1]);
|
|
Assert.Equal(0x22, secondFrame[^1]);
|
|
await AssertCode(ErrorCode.NotFound,
|
|
() => Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), firstOnlyItemId, 0, 10));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokedMemberLosesAccess()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
Svc.Join(ch.Id, ch.Member(_memberEp));
|
|
await AppendSmall(ch, ch.Member(_memberEp));
|
|
|
|
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), ch.AccountOf(_memberEp));
|
|
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
|
AssertCode(ErrorCode.Forbidden, () => Svc.Join(ch.Id, ch.Member(_memberEp))); // 被移除的账号不能凭链接再进来
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChannelsRequireALoggedInAccount()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var anonymous = new Credentials(Role.Read, ch.ReadToken, _memberEp);
|
|
AssertCode(ErrorCode.AccountRequired, () => Svc.GetChannel(ch.Id, anonymous));
|
|
AssertCode(ErrorCode.AccountRequired, () => Svc.Join(ch.Id, anonymous));
|
|
await AssertCode(ErrorCode.AccountRequired, () => LogOf(ch, anonymous));
|
|
await AssertCode(ErrorCode.AccountRequired, () => AppendSmall(ch, anonymous with { ClaimedRole = Role.Write, Token = ch.WriteToken }));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnlyTheOwnerManagesSettings()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
Svc.Join(ch.Id, ch.Member(_memberEp));
|
|
|
|
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { MaxMembers = 5 }));
|
|
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
|
|
}
|
|
|
|
[Fact]
|
|
public void OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
var unseatedOwner = ch.Owner(TestEnv.Endpoint());
|
|
|
|
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
|
|
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
|
|
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { MaxMembers = 3 }).Settings.MaxMembers);
|
|
Svc.DeleteChannel(ch.Id, unseatedOwner);
|
|
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateSettingsAppliesPatchAndRecomputesExpiry()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
|
|
|
_env.Clock.Advance(TimeSpan.FromMinutes(1));
|
|
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 3, TtlSeconds = 3600 });
|
|
|
|
Assert.Equal(3, after.Settings.MaxMembers);
|
|
Assert.Equal(3600, after.Settings.TtlSeconds);
|
|
Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt);
|
|
Assert.True(after.ExpiresAt < before.ExpiresAt);
|
|
Assert.Equal(1, _env.Events.Settings);
|
|
|
|
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 0 }));
|
|
}
|
|
|
|
// ───── 追加:幂等、形状、权限 ─────
|
|
|
|
[Fact]
|
|
public async Task AppendIsIdempotentAndDetectsConflicts()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
|
|
var first = await AppendSmall(ch, ch.Owner(_ownerEp), item);
|
|
var same = await AppendSmall(ch, ch.Owner(_ownerEp), item);
|
|
Assert.True(first.Created);
|
|
Assert.False(same.Created);
|
|
Assert.Equal(first.Seq, same.Seq);
|
|
|
|
await AssertCode(ErrorCode.Conflict, () => AppendSmall(ch, ch.Owner(_ownerEp), item, fill: 0x43));
|
|
Assert.Single(await LogOf(ch, ch.Owner(_ownerEp)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendRejectsMalformedShapes()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
|
|
// 非 GATED 条目必须是单帧且 FINAL
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, TestEnv.SmallEnc, TestEnv.EncPayload)));
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Enc, TestEnv.EncPayload)));
|
|
// 快照不能是 GATED
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)));
|
|
// enc 负载必须是 档位+16
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, TestEnv.SmallEnc, 1000)));
|
|
// 不是合法封装
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, new byte[10]));
|
|
// 超过负载上限
|
|
await AssertCode(ErrorCode.TooLarge, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final | FrameFlags.Enc, _env.Limits.MaxPayloadBytes + 1)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EncFlagMustMatchChannelMode()
|
|
{
|
|
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
|
|
var raw = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
|
|
await AssertCode(ErrorCode.ModeMismatch, () => Append(enc, enc.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100)));
|
|
await AssertCode(ErrorCode.ModeMismatch, () => Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload)));
|
|
|
|
// raw 模式负载任意长度
|
|
var ok = await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100));
|
|
Assert.True(ok.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WritePermissionsAreEnforced()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
|
|
// 读令牌不能写
|
|
await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Reader(_memberEp)));
|
|
// 写令牌可以写
|
|
Assert.True((await AppendSmall(ch, ch.Member(_memberEp))).Created);
|
|
|
|
// 关闭成员上传后,写令牌失效,所有者仍可写
|
|
Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MembersCanUpload = false });
|
|
await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Member(_memberEp)));
|
|
Assert.True((await AppendSmall(ch, ch.Owner(_ownerEp))).Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChannelWithoutWriteHashRejectsWriteRole()
|
|
{
|
|
var ch = _env.Create(_ownerEp, withWriteHash: false);
|
|
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
await AssertCode(ErrorCode.Unauthorized, () => AppendSmall(ch, ch.Member(_memberEp)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RawChannelIsReadableByIdAloneButEncIsNot()
|
|
{
|
|
var raw = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
|
|
await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10));
|
|
|
|
var anonymous = new Credentials(Role.Read, null, _memberEp, "reader"); // raw 频道知道 id 即可读,但仍要登录
|
|
Svc.Join(raw.Id, anonymous);
|
|
Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records);
|
|
|
|
AssertCode(ErrorCode.Unauthorized, () => Svc.Join(enc.Id, anonymous));
|
|
}
|
|
|
|
// ───── 日志 ─────
|
|
|
|
[Fact]
|
|
public async Task LogReturnsRecordsInOrderAndSupportsResume()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var frames = new List<byte[]>();
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
var f = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload, (byte)(i + 1));
|
|
frames.Add(f);
|
|
await Append(ch, ch.Owner(_ownerEp), f);
|
|
}
|
|
|
|
var page = await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 100);
|
|
Assert.Equal(3, page.LastSeq);
|
|
Assert.Equal([1L, 2L, 3L], page.Records.Select(r => Head(r).Seq));
|
|
// 线格式 = seq(8) ‖ kind(1) ‖ 原封装
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
Assert.Equal(LogKind.Frame, Head(page.Records[i]).Kind);
|
|
Assert.Equal(frames[i], page.Records[i].Bytes[9..]);
|
|
}
|
|
|
|
Assert.Equal(2, (await LogOf(ch, ch.Owner(_ownerEp), from: 2)).Count);
|
|
Assert.Empty(await LogOf(ch, ch.Owner(_ownerEp), from: 4));
|
|
Assert.Single((await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 1)).Records); // limit
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumingBeforeCompactionWatermarkIsGone()
|
|
{
|
|
_env.Limits.TombstoneRetentionSeconds = 1;
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
|
|
var a = TestEnv.NewId();
|
|
await AppendSmall(ch, o, a); // seq 1
|
|
Svc.DeleteItem(ch.Id, o, a); // 墓碑 seq 2
|
|
_env.Clock.Advance(TimeSpan.FromSeconds(5));
|
|
await AppendSmall(ch, o); // seq 3,顺带清理过期墓碑
|
|
|
|
var info = Svc.GetChannel(ch.Id, o);
|
|
Assert.Equal(3, info.OldestSeq);
|
|
|
|
var ex = await Assert.ThrowsAsync<WebDropException>(() => Svc.ReadLogAsync(ch.Id, o, 2, 10));
|
|
Assert.Equal(ErrorCode.Gone, ex.Code);
|
|
Assert.Equal(3, ex.OldestSeq);
|
|
|
|
Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 3, 10)).Records); // 水位之后可续传
|
|
Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 0, 10)).Records); // 全量同步永远允许
|
|
}
|
|
|
|
// ───── GATED 条目与下载 ─────
|
|
|
|
private async Task<Id16> UploadGated(TestChannel ch, Credentials who, int frames, long? maxDownloads = null)
|
|
{
|
|
var item = TestEnv.NewId();
|
|
for (uint i = 0; i < frames; i++)
|
|
{
|
|
var flags = FrameFlags.Gated | FrameFlags.Enc | (i == frames - 1 ? FrameFlags.Final : 0);
|
|
await Append(ch, who, TestEnv.Frame(item, i, flags, TestEnv.EncPayload, (byte)(i + 1)), maxDownloads);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GatedItemIsAnnouncedNotPushedAndFetchedInOrder()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = await UploadGated(ch, o, 3);
|
|
|
|
// 日志里只有一条 announce
|
|
var log = await LogOf(ch, o);
|
|
Assert.Equal([LogKind.Announce], log.Select(l => l.Kind));
|
|
|
|
Svc.StartItemDownload(ch.Id, o, item, 0);
|
|
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
|
|
Assert.Equal(3, chunk.Frames.Count);
|
|
Assert.True(chunk.Complete);
|
|
Assert.Equal(3u, chunk.NextIndex);
|
|
for (int i = 0; i < 3; i++) Assert.Equal((byte)(i + 1), chunk.Frames[i][^1]);
|
|
|
|
var tail = await Svc.ReadItemFramesAsync(ch.Id, o, item, 2, 10);
|
|
Assert.Single(tail.Frames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IncompleteGatedItemStopsAtTheGapAndIsNotComplete()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
|
|
await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1
|
|
|
|
Svc.StartItemDownload(ch.Id, o, item, 0);
|
|
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
|
|
Assert.Single(chunk.Frames);
|
|
Assert.Equal(1u, chunk.NextIndex);
|
|
Assert.False(chunk.Complete);
|
|
|
|
// FINAL 不能早于已存在的更大序号
|
|
await AssertCode(ErrorCode.BadRequest, () =>
|
|
Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GatedFrameFlagsMustStayConsistentWithinAnItem()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
|
|
|
|
// 后续帧改变 GATED 属性会被 ValidateShape 拒绝(非 GATED 必须是单帧 FINAL)
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)));
|
|
// 帧序号超出已声明的 FINAL
|
|
await Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload));
|
|
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DownloadLimitIsEnforcedWithAGraceWindowForResume()
|
|
{
|
|
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { FileMaxDownloads = 2 });
|
|
var o = ch.Owner(_ownerEp);
|
|
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
var item = await UploadGated(ch, o, 2);
|
|
|
|
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0);
|
|
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0); // 第 2 次达到上限,进入宽限期
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0));
|
|
|
|
// 宽限期内可以续传已开始的下载
|
|
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1);
|
|
Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames);
|
|
|
|
// 宽限期过后拒绝,清理任务删除密文并留下墓碑
|
|
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds));
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1));
|
|
|
|
Assert.Equal(1, Svc.Sweep());
|
|
var tombstone = (await Svc.ReadLogAsync(ch.Id, o, 0, 100)).Records.Single(r => Head(r).Kind == LogKind.Tombstone);
|
|
Assert.Equal((byte)TombstoneReason.DownloadLimit, tombstone.Bytes[^1]);
|
|
AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
|
|
{
|
|
var otherEndpoint = TestEnv.Endpoint();
|
|
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 3, FileMaxDownloads = 1 });
|
|
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
|
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
|
|
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
|
|
var otherItem = await UploadGated(ch, ch.Owner(_ownerEp), 2);
|
|
var reader = ch.Reader(_memberEp);
|
|
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, item, 1));
|
|
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10));
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(otherEndpoint), item, 1));
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, otherItem, 1));
|
|
|
|
Svc.StartItemDownload(ch.Id, reader, item, 0);
|
|
Svc.StartItemDownload(ch.Id, reader, item, 1);
|
|
var resumed = await Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10);
|
|
Assert.Single(resumed.Frames);
|
|
Assert.Equal(2u, resumed.NextIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var unlimited = await UploadGated(ch, o, 1, maxDownloads: 0);
|
|
for (int i = 0; i < 25; i++) Svc.StartItemDownload(ch.Id, o, unlimited, 0);
|
|
|
|
var one = await UploadGated(ch, o, 1, maxDownloads: 1);
|
|
Svc.StartItemDownload(ch.Id, o, one, 0);
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, one, 0));
|
|
}
|
|
|
|
// ───── 删除、快照、淘汰、过期 ─────
|
|
|
|
[Theory]
|
|
[InlineData(DeletePolicy.Own, true, true)]
|
|
[InlineData(DeletePolicy.Own, false, false)]
|
|
[InlineData(DeletePolicy.OwnerOnly, true, false)]
|
|
[InlineData(DeletePolicy.Anyone, false, true)]
|
|
public async Task MemberDeletePermissionFollowsPolicy(DeletePolicy policy, bool ownItem, bool allowed)
|
|
{
|
|
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = policy });
|
|
Svc.Join(ch.Id, ch.Member(_memberEp));
|
|
|
|
var item = TestEnv.NewId();
|
|
await AppendSmall(ch, ownItem ? ch.Member(_memberEp) : ch.Owner(_ownerEp), item);
|
|
|
|
if (allowed) Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item);
|
|
else AssertCode(ErrorCode.Forbidden, () => Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OwnerCanAlwaysDeleteAndDeletionLeavesATombstone()
|
|
{
|
|
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = DeletePolicy.OwnerOnly });
|
|
Svc.Join(ch.Id, ch.Member(_memberEp));
|
|
var item = TestEnv.NewId();
|
|
await AppendSmall(ch, ch.Member(_memberEp), item);
|
|
|
|
Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item);
|
|
|
|
var log = await LogOf(ch, ch.Owner(_ownerEp));
|
|
Assert.Equal([LogKind.Tombstone], log.Select(l => l.Kind)); // 帧记录已随条目删除
|
|
AssertCode(ErrorCode.NotFound, () => Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewSnapshotSupersedesTheOldOne()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 1
|
|
var second = await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 2,墓碑 seq 3
|
|
await AppendSmall(ch, o); // 普通条目不受影响,seq 4
|
|
|
|
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
|
Assert.Equal([(2L, LogKind.Frame), (3L, LogKind.Tombstone), (4L, LogKind.Frame)], page.Records.Select(Head));
|
|
Assert.Equal((byte)TombstoneReason.Superseded, page.Records[1].Bytes[^1]);
|
|
Assert.Equal(2, second.Seq);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExceedingMaxItemsEvictsTheOldest()
|
|
{
|
|
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxItems = 3 });
|
|
var o = ch.Owner(_ownerEp);
|
|
var ids = new List<Id16>();
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
ids.Add(TestEnv.NewId());
|
|
await AppendSmall(ch, o, ids[i]);
|
|
_env.Clock.Advance(TimeSpan.FromMilliseconds(10));
|
|
}
|
|
|
|
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
|
var frames = page.Records.Where(r => Head(r).Kind == LogKind.Frame).ToList();
|
|
var tombstones = page.Records.Where(r => Head(r).Kind == LogKind.Tombstone).ToList();
|
|
Assert.Equal(3, frames.Count);
|
|
Assert.Equal(2, tombstones.Count);
|
|
Assert.All(tombstones, t => Assert.Equal((byte)TombstoneReason.Evicted, t.Bytes[^1]));
|
|
// 保留的是最新的三个
|
|
Assert.Equal(ids.Skip(2).Select(i => i.ToArray()), frames.Select(f => f.Bytes[(9 + 2)..(9 + 2 + 16)]));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SweepRemovesExpiredItemsButKeepsLiveOnes()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var old = TestEnv.NewId();
|
|
await AppendSmall(ch, o, old);
|
|
|
|
_env.Clock.Advance(TimeSpan.FromHours(12));
|
|
var fresh = TestEnv.NewId();
|
|
await AppendSmall(ch, o, fresh); // 续期频道,新条目 TTL 从现在起算
|
|
|
|
_env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在
|
|
var beforeCleanup = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
|
Assert.Single(beforeCleanup.Records);
|
|
Assert.Equal(fresh.ToArray(), beforeCleanup.Records[0].Bytes[(9 + 2)..(9 + 2 + 16)]);
|
|
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, old, 0));
|
|
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, o, old, 0, 10));
|
|
|
|
Assert.Equal(1, Svc.Sweep());
|
|
|
|
var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
|
Assert.Equal([LogKind.Frame, LogKind.Tombstone], log.Records.Select(r => Head(r).Kind));
|
|
Assert.Equal((byte)TombstoneReason.Expired, log.Records[1].Bytes[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SweepDeletesExpiredChannelsAndTheirBlobs()
|
|
{
|
|
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100_000)); // 走 blob
|
|
Assert.True(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex())));
|
|
|
|
_env.Clock.Advance(TimeSpan.FromHours(25));
|
|
Svc.Sweep();
|
|
|
|
Assert.False(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex())));
|
|
Assert.Equal(1, _env.Events.Deleted);
|
|
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteChannelRemovesEverything()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
await AppendSmall(ch, ch.Owner(_ownerEp));
|
|
Svc.DeleteChannel(ch.Id, ch.Owner(_ownerEp));
|
|
|
|
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
|
Assert.Equal(1, _env.Events.Deleted);
|
|
}
|
|
|
|
// ───── blob 路径 ─────
|
|
|
|
[Fact]
|
|
public async Task LargeFramesGoToBlobStoreAndRoundTrip()
|
|
{
|
|
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x7A);
|
|
await Append(ch, o, frame);
|
|
|
|
string itemDir = Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex());
|
|
Assert.Single(Directory.GetFiles(itemDir));
|
|
|
|
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10);
|
|
Assert.Equal(frame, page.Records.Single().Bytes[9..]);
|
|
|
|
// 幂等重发不会重复落盘
|
|
await Append(ch, o, frame);
|
|
Assert.Single(Directory.GetFiles(itemDir));
|
|
|
|
Svc.DeleteItem(ch.Id, o, item);
|
|
Assert.False(Directory.Exists(itemDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConflictingLargeFrameLeavesNoOrphanBlob()
|
|
{
|
|
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x01));
|
|
|
|
await AssertCode(ErrorCode.Conflict, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x02)));
|
|
Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex())));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OrphanBlobSweepRemovesOnlyUnreferencedOldFiles()
|
|
{
|
|
// 孤立判断基于真实文件时间,所以时钟要先对齐到真实时间,再建频道
|
|
_env.Clock.NowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
var item = TestEnv.NewId();
|
|
await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(item, 0, FrameFlags.Final, 100_000));
|
|
|
|
await _env.Blobs.PutAsync("aa/bb/0.dead", new byte[10]);
|
|
await _env.Blobs.PutAsync("aa/bb/1.fresh", new byte[10]);
|
|
string stale = Path.Combine(_env.Dir, "blobs", "aa", "bb", "0.dead");
|
|
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-1));
|
|
|
|
Svc.Sweep(includeOrphanBlobs: true);
|
|
|
|
Assert.False(File.Exists(stale)); // 孤立且够旧:删除
|
|
Assert.True(File.Exists(Path.Combine(_env.Dir, "blobs", "aa", "bb", "1.fresh"))); // 孤立但太新:保留
|
|
Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex()))); // 被引用:保留
|
|
}
|
|
|
|
// ───── 并发 ─────
|
|
|
|
[Fact]
|
|
public async Task ParallelAppendsGetUniqueContiguousSeqs()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
|
|
var results = await Task.WhenAll(Enumerable.Range(0, 60).Select(_ => Task.Run(() => AppendSmall(ch, o))));
|
|
|
|
Assert.All(results, r => Assert.True(r.Created));
|
|
Assert.Equal(Enumerable.Range(1, 60).Select(i => (long)i), results.Select(r => r.Seq).Order());
|
|
Assert.Equal(60, (await LogOf(ch, o)).Count);
|
|
Assert.Equal(60, Svc.GetChannel(ch.Id, o).LastSeq);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParallelIdenticalAppendsCreateExactlyOneFrame()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var o = ch.Owner(_ownerEp);
|
|
var frame = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload);
|
|
|
|
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => Append(ch, o, frame))));
|
|
|
|
Assert.Equal(1, results.Count(r => r.Created));
|
|
Assert.Single(results.Select(r => r.Seq).Distinct());
|
|
Assert.Single(await LogOf(ch, o));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParallelIdenticalLargeAppendsKeepTheBlob()
|
|
{
|
|
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
|
var o = ch.Owner(_ownerEp);
|
|
var item = TestEnv.NewId();
|
|
var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000);
|
|
|
|
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(() => Append(ch, o, frame))));
|
|
|
|
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10);
|
|
Assert.Equal(frame, page.Records.Single().Bytes[9..]); // blob 仍在且内容正确
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendPublishesTheNewSeq()
|
|
{
|
|
var ch = _env.Create(_ownerEp);
|
|
var r = await AppendSmall(ch, ch.Owner(_ownerEp));
|
|
Assert.Contains(_env.Events.Advances, e => e.Channel == ch.Id && e.Seq == r.Seq);
|
|
}
|
|
}
|