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 act) => Assert.Equal(code, (await Assert.ThrowsAsync(act)).Code); private static void AssertCode(ErrorCode code, Action act) => Assert.Equal(code, Assert.Throws(act).Code); private Task Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null) => Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads); private Task 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> 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, ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)), OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)), }; AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "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, ReadHash = h(), OwnerHash = h() }; var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() }; var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw }; AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner")); AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner")); AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner")); } [Fact] public void CreateSeatsTheOwnerAndReportsDefaults() { var ch = _env.Create(_ownerEp); var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)); Assert.Equal(1, info.SeatsUsed); Assert.Equal(2, info.Settings.Seats); 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))); AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp))); // 用读令牌冒充所有者 AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp))); } [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.EndpointHash, again.EndpointHash); Assert.Equal(2, again.SeatsUsed); Assert.Equal(2, again.SeatsMax); AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint()))); Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知 } [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))); } [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 ReleasedSeatLosesAccess() { var ch = _env.Create(_ownerEp); var joined = Svc.Join(ch.Id, ch.Member(_memberEp)); await AppendSmall(ch, ch.Member(_memberEp)); Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash)); await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp))); } private static byte[] Codec_Decode(string b64) => Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException(); [Fact] public void OnlyTheOwnerManagesSeatsAndSettings() { var ch = _env.Create(_ownerEp); Svc.Join(ch.Id, ch.Member(_memberEp)); AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp))); AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 })); AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp))); AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp)); var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)); Assert.Equal(2, seats.Count); } [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 { Seats = 3 }).Settings.Seats); Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count); AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint())); Svc.DeleteChannel(ch.Id, unseatedOwner); AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp))); } [Fact] public void SeatLabelIsStoredAndBounded() { var ch = _env.Create(_ownerEp); Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]); var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single(); Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label); AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257])); AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(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 { Seats = 3, TtlSeconds = 3600 }); Assert.Equal(3, after.Settings.Seats); 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 { Seats = 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); 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(); 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(() => 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 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 { Seats = 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(); 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); } }