diff --git a/.github/workflows/coupon-real-test.yml b/.github/workflows/coupon-real-test.yml index 583cc39..ea0f83b 100644 --- a/.github/workflows/coupon-real-test.yml +++ b/.github/workflows/coupon-real-test.yml @@ -46,7 +46,7 @@ env: jobs: real-test: runs-on: ubuntu-22.04-arm - timeout-minutes: 30 + timeout-minutes: 50 # VPN peer is single-occupancy; concurrent connections break DNS (2026-07-23 incident, # see alita-real-test.yml). This run queues behind production deploys of every bot — # that is correct, not a bug. diff --git a/tests/CouponHubBot.RealTests/AddFlowRealTests.fs b/tests/CouponHubBot.RealTests/AddFlowRealTests.fs index 1cd1b82..d741b3c 100644 --- a/tests/CouponHubBot.RealTests/AddFlowRealTests.fs +++ b/tests/CouponHubBot.RealTests/AddFlowRealTests.fs @@ -54,9 +54,20 @@ type AddFlowRealTests(fx: RealAssemblyFixture) = TestRetry.withTimeoutRetry (fun () -> task { fx.SkipUnlessUserClient() - let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, "5_25_01-15_01-21_2706528422291.jpg") + let fixtureImage = "5_25_01-15_01-21_2706528422291.jpg" + let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, fixtureImage) Assert.True(File.Exists imagePath, $"Expired-coupon fixture missing: {imagePath}") + // Idempotence (not a hollowing-out of this test's own subject — the manual + // /add path itself is still what's being exercised below, unlike + // RealTestHelpers.addCouponViaCaptionAsync, which this test deliberately does + // NOT call): delete any pre-existing row for this fixture's barcode FIRST, so + // a leftover coupon from any other test that shares this fixture (or from + // TestRetry's own retry of THIS test) can never turn the "Добавлен купон" + // confirmation below into a "Купон с таким штрихкодом уже есть в базе…" + // rejection — see README.md's "Fixture reuse rule" section. + do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString RealTestHelpers.fixtureBarcodes.[fixtureImage] + let caption = $"/add 5 25 {futureExpiry}" let! sentId = fx.UserClient.SendPhoto(fx.BotChatId, imagePath, caption) let! _reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Добавлен купон", TimeSpan.FromSeconds 90.) diff --git a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs new file mode 100644 index 0000000..3a17707 --- /dev/null +++ b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs @@ -0,0 +1,512 @@ +namespace CouponHubBot.RealTests + +open System +open System.Globalization +open System.IO +open System.Threading.Tasks +open Dapper +open Npgsql +open Xunit +open TL + +/// Dapper-mappable row for `latestCouponForOwner` below — a named record (not an +/// anonymous record) because Dapper's default QuerySingleAsync mapping needs +/// settable properties, which F# anonymous records don't provide. +[] +type private WizardCouponRow = + { id: int + value: decimal + min_check: decimal } + +/// Coverage: the interactive `/add` wizard (bare "/add" -> photo -> discount/date/confirm) +/// and the separate album-flow `addflow:bulk:cancel:` button. +/// +/// ── The state machine, as determined from the source (all 16 "good" fixtures in +/// tests/CouponHubBot.Ocr.Tests/Images/ — everything except the two "low quality" ones — +/// make OCR recognize barcode+value+min_check+valid_to reliably; see OcrTests.fs:216-232 +/// and RealTestHelpers.fs's fixtureBarcodes doc comment) ── +/// +/// 1. Bare "/add" -> `HandleAddWizardStart` (CouponFlowHandler.fs:72-97) sets +/// stage="awaiting_photo" and asks for a photo. +/// 2. A bare (caption-less) photo at that stage -> `TryHandleWizardMessage` +/// (CouponFlowHandler.fs:383-388) -> `HandleAddWizardPhoto` +/// (CouponFlowHandler.fs:179-349). Because every fixture yields a full OCR match +/// (barcode + value + min_check + valid_to all present), this ALWAYS lands on +/// stage="awaiting_ocr_confirm" (CouponFlowHandler.fs:289-308) with `expires_at` set +/// to OCR's own (past, printed-on-the-photo) date, and shows the +/// `addflow:ocr:yes` / `addflow:ocr:no` keyboard. +/// 3. `addflow:ocr:no` (CallbackHandler.fs:296-309) moves to +/// stage="awaiting_discount_choice" and — critically — EXPLICITLY CLEARS +/// `expires_at` back to `Nullable()` (CallbackHandler.fs:303). This is what keeps +/// the expired-fixture trap described in this file's originating brief from firing: +/// every subsequent `addflow:disc:*` press (CallbackHandler.fs:203-231) checks +/// `flow.expires_at.HasValue`, which is now false, so it ALWAYS lands on +/// stage="awaiting_date_choice" (CouponFlowHandler.fs:351-354) rather than jumping +/// straight to confirm with the stale past date. The date step is therefore always +/// reached on this route, and `addflow:date:today` / `addflow:date:tomorrow` +/// (CallbackHandler.fs:232-257) write a FRESH, non-expired `expires_at` before the +/// confirm screen — so `addflow:confirm` (CallbackHandler.fs:310-340) always calls +/// `TryAddCoupon` with a valid date and there is a genuine happy path. No SQL +/// workaround (unlike BulkAddRealTests.fs's `bumpItemsToFutureExpiry`) is needed +/// here — `addflow:ocr:no` already does the equivalent for the interactive wizard. +/// +/// ── `addflow:ocr:yes` — reachable only via a documented SQL workaround (see the test +/// below for the full citation) ── +/// `addflow:ocr:yes` (CallbackHandler.fs:258-293) calls `TryAddCoupon` directly with +/// `flow.expires_at.Value` — the OCR-derived date, which for every fixture in this +/// public repo is deliberately in the past (repo-public constraint, see +/// AddFlowRealTests.fs's doc comment). `DbService.TryAddCoupon` rejects +/// `expires_at < todayUtc()` unconditionally (DbService.fs:198-200) BEFORE any DB +/// write, so `addflow:ocr:yes` against an UNMODIFIED fixture can only ever reach the +/// "Нельзя добавить истёкший купон" rejection branch (CallbackHandler.fs:285-287) — +/// never `AddCouponResult.Added`. There is no photo in this fixture set whose printed +/// date is in the future (every fixture is intentionally expired), so the Added-branch +/// happy path can only be reached by overwriting the PENDING WIZARD ROW's own +/// `pending_add.expires_at` via direct SQL after OCR lands and before pressing "yes" — +/// the same trick BulkAddRealTests.fs's `bumpItemsToFutureExpiry` already uses for the +/// album path's `pending_add_batch_item.expires_at`. See the test below for why this is +/// safe (it overrides the wizard's OWN staged expiry, not the fixture photo) and why it +/// must never be "cleaned up". +type AddWizardRealTests(fx: RealAssemblyFixture) = + + let pollInterval = TimeSpan.FromMilliseconds 500. + + /// Local copy of BulkAddRealTests.fs's `waitFor` (private to that file's type, not + /// reusable across files per this task's ownership boundary) — generic DB-poll + /// helper for the bulk-cancel test below. + let waitFor (description: string) (timeout: TimeSpan) (check: unit -> Task<'a option>) = + task { + let deadline = DateTime.UtcNow + timeout + let mutable result = None + while result.IsNone && DateTime.UtcNow < deadline do + let! r = check () + result <- r + if result.IsNone then do! Task.Delay pollInterval + match result with + | Some v -> return v + | None -> return raise (AwaitTimeoutException $"Timed out waiting for {description} after {timeout.TotalSeconds}s") + } + + let waitForBatchByOwner (ownerId: int64) = + waitFor "pending_add_batch row" (TimeSpan.FromSeconds 15.) (fun () -> + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! id = + conn.QuerySingleAsync( + "SELECT COALESCE((SELECT id FROM pending_add_batch WHERE user_id=@u ORDER BY id DESC LIMIT 1), 0)", + {| u = ownerId |}) + return if id = 0L then None else Some id + }) + + let waitForAllItemsTerminal (batchId: int64) = + waitFor "batch items to leave 'pending'" (TimeSpan.FromSeconds 60.) (fun () -> + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! pendingCount = + conn.QuerySingleAsync( + "SELECT COUNT(*)::bigint FROM pending_add_batch_item WHERE batch_id=@b AND status='pending'", + {| b = batchId |}) + return if pendingCount = 0L then Some () else None + }) + + let waitForBatchStatus (batchId: int64) (expected: string) = + waitFor $"batch {batchId} status='{expected}'" (TimeSpan.FromSeconds 30.) (fun () -> + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! status = + conn.QuerySingleAsync( + "SELECT COALESCE((SELECT status FROM pending_add_batch WHERE id=@b), '__GONE__')", + {| b = batchId |}) + return if status = expected then Some () else None + }) + + let waitForBatchCleared (batchId: int64) = + waitFor $"batch {batchId} cleared" (TimeSpan.FromSeconds 30.) (fun () -> + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! count = conn.QuerySingleAsync("SELECT COUNT(*)::bigint FROM pending_add_batch WHERE id=@b", {| b = batchId |}) + return if count = 0L then Some () else None + }) + + let countCouponsForOwner (ownerId: int64) = + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + return! conn.QuerySingleAsync("SELECT COUNT(*)::bigint FROM coupon WHERE owner_id=@u", {| u = ownerId |}) + } + + let latestCouponForOwner (ownerId: int64) = + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + return! + conn.QuerySingleAsync( + "SELECT id, value, min_check FROM coupon WHERE owner_id = @owner_id ORDER BY id DESC LIMIT 1", + {| owner_id = ownerId |}) + } + + /// No DbSeed.fs helper exists for this (contract: "add it as a private function + /// inside your own file" when a shared helper is missing) — overwrites the PENDING + /// WIZARD ROW's own `expires_at` (table `pending_add`, column `expires_at` — schema + /// confirmed against V6__recreate_pending_add.sql / V7__pending_add_barcode.sql, NOT + /// guessed), the same table DbSeed.deletePendingAddFlowAsync already targets. This is + /// what makes the `addflow:ocr:yes` test below (Fix 4) reachable — see that test's + /// doc comment for the full "why" and the public-repo constraint that makes this SQL + /// bump necessary at all. + let bumpPendingAddExpiryAsync (userId: int64) (expiresAt: string) : Task = + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! _ = + conn.ExecuteAsync( + "UPDATE pending_add SET expires_at = @expires_at::date WHERE user_id = @user_id", + {| user_id = userId; expires_at = expiresAt |}) + () + } + + /// Drives a fresh wizard from a bare "/add" through a bare (caption-less) photo of + /// `fixtureImage`, through the OCR-confirm screen's `addflow:ocr:no`, and returns + /// the ensuing "Выбери скидку и минимальный чек" screen (with the four + /// `addflow:disc:*` buttons — BotHelpers.fs:253-259). See class doc comment for why + /// `addflow:ocr:no` is always reachable and always the right way past the + /// OCR-derived (expired) date. + let reachDiscountChoice (fixtureImage: string) : Task = + task { + let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, fixtureImage) + Assert.True(File.Exists imagePath, $"Expired-coupon fixture missing: {imagePath}") + + let! addSentId = fx.UserClient.SendText(fx.BotChatId, "/add") + let! _askPhoto = fx.UserClient.AwaitTextContaining(fx.BotChatId, addSentId, "Пришли фото", TimeSpan.FromSeconds 60.) + + let! photoSentId = fx.UserClient.SendPhoto(fx.BotChatId, imagePath, "") + + // Real Azure OCR call happens synchronously inside HandleAddWizardPhoto — + // same 90s budget AddFlowRealTests.fs gives its own OCR+add round trip. + let! _ocrConfirmMsg = + fx.UserClient.AwaitAndPressButton( + fx.BotChatId, + photoSentId, + "Всё верно?", + TimeSpan.FromSeconds 90., + (fun d -> d = "addflow:ocr:no"), + "addflow:ocr:no") + + return! fx.UserClient.AwaitTextContaining(fx.BotChatId, photoSentId, "выбери скидку", TimeSpan.FromSeconds 60.) + } + + /// Presses `discountData` (e.g. "addflow:disc:10:50") on `discountMsg`, then + /// `addflow:date:today` on the ensuing date-choice screen (always reached — see + /// class doc comment), and returns the resulting confirm screen. + let pickDiscountReachConfirm (discountMsg: TL.Message) (discountData: string) : Task = + task { + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, discountMsg, (fun d -> d = discountData), discountData) + let! dateMsg = + fx.UserClient.AwaitAndPressButton( + fx.BotChatId, + discountMsg.id, + "Выбери дату истечения", + TimeSpan.FromSeconds 60., + (fun d -> d = "addflow:date:today"), + "addflow:date:today") + return! fx.UserClient.AwaitTextContaining(fx.BotChatId, dateMsg.id, "Подтвердить добавление купона", TimeSpan.FromSeconds 60.) + } + + /// Test 1 (contract item 1): full wizard happy path. bare "/add" -> photo -> + /// addflow:ocr:no -> addflow:disc:10:50 -> addflow:date:today -> addflow:confirm. + /// Asserts "Добавлен купон ID:" and that the coupon lands in the DB with the + /// chosen value/min_check. + [] + member _.``full wizard happy path: ocr:no, disc:10:50, date:today, confirm adds a coupon``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let ownerId = fx.UserClient.Me.id + do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId + do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId + + let fixtureImage = "10_50_01-19_01-28_2706613152454.jpg" + let barcode = RealTestHelpers.fixtureBarcodes.[fixtureImage] + do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString barcode + + let! discountMsg = reachDiscountChoice fixtureImage + let! confirmMsg = pickDiscountReachConfirm discountMsg "addflow:disc:10:50" + Assert.Contains("10€ из 50€", confirmMsg.message) + + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, confirmMsg, (fun d -> d = "addflow:confirm"), "addflow:confirm") + let! addedMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Добавлен купон ID:", TimeSpan.FromSeconds 60.) + + let! coupon = latestCouponForOwner ownerId + Assert.Equal(10m, coupon.value) + Assert.Equal(50m, coupon.min_check) + Assert.Contains($"ID:{coupon.id}", addedMsg.message) + }) + + /// Test 2 (contract item 2): addflow:date:today vs addflow:date:tomorrow. + /// + /// Rewritten (review Fix 3 + Fix 1): the original version asserted + /// `formatUiDate (DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1.0)))` against the + /// confirm screen — a date computed from the TEST PROCESS's own clock, which + /// disagrees with the bot's frozen-and-advanceable `FakeTimeProvider` notion of + /// "today" whenever the pod booted on the other side of a UTC midnight from this + /// process, or after any earlier test in the run advanced the clock. It also never + /// pressed `addflow:confirm` or `addflow:cancel`, leaving a pending `pending_add` + /// row behind for a different, unrelated test to sweep — an ordering dependency in a + /// suite with no guaranteed test order. + /// + /// This version drives TWO full wizard flows to completion (through + /// `addflow:confirm`, one per date button, distinct fixtures so their barcodes never + /// collide) and compares the resulting `coupon.expires_at` DB rows directly to each + /// other. That proves the RELATIONSHIP ("tomorrow" is exactly one day after "today", + /// and the two are different) entirely from the bot's own recorded state — no + /// reference to this test process's `DateTime.UtcNow` anywhere in the assertion — + /// and each sub-flow reaches `addflow:confirm`, so nothing is left pending afterward. + [] + member _.``date:today and date:tomorrow set different, one-day-apart expiries``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let ownerId = fx.UserClient.Me.id + + let addViaDateButtonAndGetExpiry (fixtureImage: string) (dateButtonData: string) : Task = + task { + do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId + do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId + + let barcode = RealTestHelpers.fixtureBarcodes.[fixtureImage] + do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString barcode + + let! discountMsg = reachDiscountChoice fixtureImage + do! + fx.UserClient.PressCallbackButtonMatching( + fx.BotChatId, discountMsg, (fun d -> d = "addflow:disc:10:50"), "addflow:disc:10:50") + let! dateMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, discountMsg.id, "Выбери дату истечения", TimeSpan.FromSeconds 60.) + + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, dateMsg, (fun d -> d = dateButtonData), dateButtonData) + let! confirmMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, dateMsg.id, "Подтвердить добавление купона", TimeSpan.FromSeconds 60.) + + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, confirmMsg, (fun d -> d = "addflow:confirm"), "addflow:confirm") + let! _addedMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Добавлен купон ID:", TimeSpan.FromSeconds 60.) + + // NOT QuerySingleAsync against a bare scalar column — root- + // caused (run 30181924500, failure 5): Dapper 2.1.72 doesn't recognize + // `DateOnly` as one of its "simple"/scalar-mappable result types, so + // `QuerySingleAsync` falls back to its reflection-based + // object mapper, which looks for a settable member NAMED "expires_at" + // on `DateOnly` (a struct with no such member, only Year/Month/Day) — + // finds none, leaves the value at `default(DateOnly)` = 0001-01-01, + // and returns THAT with no error and no row-count mismatch (a row was + // genuinely found; only the column-to-scalar mapping silently no-ops). + // That is exactly what "Expected: 01/02/0001, Actual: 01/01/0001" is: + // both `todayExpiry` and `tomorrowExpiry` are `DateOnly.MinValue`. + // Mapping into `CouponRow` (a named record with an `expires_at: DateOnly` + // field, already used successfully elsewhere in this same file and in + // AddFlowRealTests.fs) uses Dapper's ordinary per-column-by-name row + // mapper instead, which works. + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! coupon = + conn.QuerySingleAsync( + "SELECT id, value, min_check, expires_at, barcode_text FROM coupon WHERE owner_id = @owner_id ORDER BY id DESC LIMIT 1", + {| owner_id = ownerId |}) + return coupon.expires_at + } + + let! todayExpiry = addViaDateButtonAndGetExpiry "10_50_01-12_01-21_2706513420233.jpg" "addflow:date:today" + let! tomorrowExpiry = addViaDateButtonAndGetExpiry "10_50_01-12_01-21_2706530490622.jpg" "addflow:date:tomorrow" + + Assert.Equal(todayExpiry.AddDays(1), tomorrowExpiry) + Assert.NotEqual(todayExpiry, tomorrowExpiry) + }) + + /// Test 3 (contract item 3): addflow:cancel. Reaches the confirm screen, cancels, + /// asserts the cancellation text and that no coupon was created for this fixture's + /// barcode. + [] + member _.``addflow:cancel aborts without creating a coupon``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let ownerId = fx.UserClient.Me.id + do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId + do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId + + let fixtureImage = "10_50_01-14_01-23_2706658654210.jpg" + let barcode = RealTestHelpers.fixtureBarcodes.[fixtureImage] + do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString barcode + + let! discountMsg = reachDiscountChoice fixtureImage + let! confirmMsg = pickDiscountReachConfirm discountMsg "addflow:disc:10:50" + + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, confirmMsg, (fun d -> d = "addflow:cancel"), "addflow:cancel") + let! _cancelReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Ок, добавление купона отменено.", TimeSpan.FromSeconds 60.) + + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! count = conn.QuerySingleAsync("SELECT COUNT(*)::bigint FROM coupon WHERE barcode_text = @b", {| b = barcode |}) + Assert.Equal(0L, count) + }) + + /// Test 4 (contract item 4): the three addflow:disc:* buttons NOT exercised by test + /// 1 (which used "addflow:disc:10:50" — BotHelpers.addWizardDiscountKeyboard, + /// BotHelpers.fs:253-259). Kept cheap per the brief: no addflow:confirm / DB insert + /// in any iteration — each iteration reaches the confirm screen (the only screen + /// that echoes the chosen value/min_check in text — see class doc comment on why + /// the intermediate date-choice screen does not) and cancels. Reuses the same + /// fixture across all three iterations: since no TryAddCoupon call ever happens + /// here, there is no barcode-uniqueness concern to route around. + [] + member _.``remaining discount buttons reflect the chosen value/min-check on the confirm screen``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let ownerId = fx.UserClient.Me.id + let fixtureImage = "5_25_01-15_01-21_2706666377231.jpg" + + let remaining = + [ "addflow:disc:5:25", "5€ из 25€" + "addflow:disc:10:40", "10€ из 40€" + "addflow:disc:20:100", "20€ из 100€" ] + + for discountData, expectedValueText in remaining do + do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId + do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId + + let! discountMsg = reachDiscountChoice fixtureImage + let! confirmMsg = pickDiscountReachConfirm discountMsg discountData + Assert.Contains(expectedValueText, confirmMsg.message) + + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, confirmMsg, (fun d -> d = "addflow:cancel"), "addflow:cancel") + let! _cancelReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Ок, добавление купона отменено.", TimeSpan.FromSeconds 60.) + () + }) + + /// Test 5 (Fix 4, review follow-up): addflow:ocr:yes. Previously untestable as a + /// happy path — see class doc comment — because `addflow:ocr:yes` + /// (CallbackHandler.fs:258-293) calls `TryAddCoupon` with the wizard's OWN staged + /// `expires_at`, which for a bare (caption-less) photo of any fixture in this public + /// repo is OCR's own PRINTED date, always in the past (repo-public constraint: a + /// fixture with a future printed date would be a currently-usable, live coupon + /// barcode). `DbService.TryAddCoupon` rejects `expires_at < todayUtc()` unconditionally + /// (DbService.fs:198-200) before any DB write. + /// + /// WHY THE SQL BUMP BELOW EXISTS — READ BEFORE "CLEANING THIS UP": fixture photos + /// must stay expired forever (public-repo constraint above), so the printed date OCR + /// reads off them can never be future. `bumpPendingAddExpiryAsync` overwrites only + /// the PENDING WIZARD ROW's staged `pending_add.expires_at` (the value + /// `addflow:ocr:yes` will hand to `TryAddCoupon`), not the fixture photo itself — the + /// same "override the DB row that gates TryAddCoupon, never the photo" + /// trick BulkAddRealTests.fs's `bumpItemsToFutureExpiry` already uses for the album + /// path's `pending_add_batch_item.expires_at`. Without this bump, `addflow:ocr:yes` + /// can only ever be exercised against the "Нельзя добавить истёкший купон" rejection + /// branch — this bump is the only way to reach `AddCouponResult.Added` through this + /// specific button without publishing a live barcode. Removing it "as unnecessary + /// scaffolding" would silently regress this test back to only covering rejection. + [] + member _.``addflow:ocr:yes adds a coupon using OCR's own value/min-check once the staged expiry is future``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let ownerId = fx.UserClient.Me.id + do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId + do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId + + let fixtureImage = "10_50_01-04_01-13_2706602781191.jpg" + let barcode = RealTestHelpers.fixtureBarcodes.[fixtureImage] + do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString barcode + + let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, fixtureImage) + Assert.True(File.Exists imagePath, $"Expired-coupon fixture missing: {imagePath}") + + let! addSentId = fx.UserClient.SendText(fx.BotChatId, "/add") + let! _askPhoto = fx.UserClient.AwaitTextContaining(fx.BotChatId, addSentId, "Пришли фото", TimeSpan.FromSeconds 60.) + + let! photoSentId = fx.UserClient.SendPhoto(fx.BotChatId, imagePath, "") + // Real Azure OCR call happens synchronously inside HandleAddWizardPhoto — + // same 90s budget reachDiscountChoice/AddFlowRealTests.fs give this round trip. + let! ocrConfirmMsg = + fx.UserClient.AwaitTextContaining(fx.BotChatId, photoSentId, "Всё верно?", TimeSpan.FromSeconds 90.) + + // Bump the staged wizard row's expiry PAST TryAddCoupon's expiry gate — see + // this test's doc comment for why this (and never touching the fixture + // photo itself) is the correct and only way to reach the Added branch. + let futureDate = RealTestHelpers.futureExpiry 365. + do! bumpPendingAddExpiryAsync ownerId futureDate + + do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, ocrConfirmMsg, (fun d -> d = "addflow:ocr:yes"), "addflow:ocr:yes") + let! addedMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, ocrConfirmMsg.id, "Добавлен купон ID:", TimeSpan.FromSeconds 60.) + + let! coupon = latestCouponForOwner ownerId + // OCR-derived value/min_check for this fixture — reliably 10/50, per the + // filename convention and OcrTests.fs's own theory case for this exact file + // (class doc comment: all 16 "good" fixtures OCR value+min_check+valid_to + // reliably). + Assert.Equal(10m, coupon.value) + Assert.Equal(50m, coupon.min_check) + Assert.Contains($"ID:{coupon.id}", addedMsg.message) + + use conn = new NpgsqlConnection(fx.DbConnectionString) + let! couponRow = + conn.QuerySingleAsync( + "SELECT id, value, min_check, expires_at, barcode_text FROM coupon WHERE id = @coupon_id", + {| coupon_id = coupon.id |}) + Assert.Equal(DateOnly.ParseExact(futureDate, "yyyy-MM-dd", CultureInfo.InvariantCulture), couponRow.expires_at) + Assert.Equal(barcode, couponRow.barcode_text) + }) + + /// Test 6 (contract item 6): addflow:bulk:cancel:. Album mechanics copied + /// from BulkAddRealTests.fs's confirm test, minus its `bumpItemsToFutureExpiry` + /// step — BulkBatchCancel (CallbackHandler.fs:40-52) only calls db.ClearBatch and + /// edits the message, it never calls TryAddCoupon, so there is no expiry gate to + /// route around for a cancel-only test. The "↩️ Отменить" button + /// (`addflow:bulk:cancel:`) is present on the bulk-confirm keyboard + /// regardless of how many items OCR'd successfully (BotHelpers.addBatchConfirmKeyboard, + /// BotHelpers.fs:279-287), so this doesn't depend on OCR outcome the way + /// BulkAddRealTests' confirm test does. + [] + member _.``addflow:bulk:cancel cancels the album without adding coupons``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let ownerId = fx.UserClient.Me.id + do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId + do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId + + let images = + [ "10_50_2026-01-17_2026-01-26_2706688198821.jpg" + "10_50_2026-01-17_2026-01-26_2706688198838.jpg" ] + |> List.map (fun f -> Path.Combine(RealEnv.ocrFixtureImagesDir, f)) + images |> List.iter (fun p -> Assert.True(File.Exists p, $"Expired-coupon fixture missing: {p}")) + + let! countBefore = countCouponsForOwner ownerId + + let! lastAlbumMsgId = fx.UserClient.SendAlbum(fx.BotChatId, images) + + let! batchId = waitForBatchByOwner ownerId + do! waitForAllItemsTerminal batchId + do! fx.AdvanceClockAsync 2000 // BATCH_DEBOUNCE_MS=1000 (contract seed) + margin + do! waitForBatchStatus batchId "awaiting_user" + + let! confirmMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, lastAlbumMsgId, "Подтвердить", TimeSpan.FromSeconds 30.) + do! + fx.UserClient.PressCallbackButtonMatching( + fx.BotChatId, + confirmMsg, + (fun d -> d.StartsWith "addflow:bulk:cancel:"), + "addflow:bulk:cancel:") + + // NOT an AwaitTextContaining("Ок, пакет отменён.", ...) here — root-caused (run + // 30181924500, failure 4): BulkBatchCancel's outcome text is delivered via + // `EditBulkOrSend` (CallbackHandler.fs:29-38,51), which EDITS the existing + // `confirmMsg` in place (Telegram `messages.editMessage`, not a new send) when + // `batch.bulk_message_id` is set — which it always is here, to `confirmMsg.id` + // itself. TgUserClient.fs's `record` (TgUserClient.fs:76-78) explicitly drops + // every `UpdateEditMessage` (`if not isEdit then ...`), so no Await* call can + // EVER observe an edited message's new text — this awaited a message that, + // structurally, could never arrive, hence a 100%-reproducible timeout on both + // TestRetry attempts (bot pod logs confirm "Batch N cancelled by user ... had_ok + // _items=True" succeeded server-side both times). BulkAddRealTests.fs's own + // confirm test already avoids this exact trap by DB-polling + // (`waitForBatchCleared`) instead of awaiting the outcome as Telegram text; this + // reuses the same, already-defined-in-this-file helper for the same reason. The + // `countAfter = countBefore` assertion below is the real proof the cancel (not a + // confirm) is what happened. + do! waitForBatchCleared batchId + + let! countAfter = countCouponsForOwner ownerId + Assert.Equal(countBefore, countAfter) + }) diff --git a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs new file mode 100644 index 0000000..cef1dbc --- /dev/null +++ b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs @@ -0,0 +1,253 @@ +namespace CouponHubBot.RealTests + +open System +open System.Threading.Tasks +open Dapper +open Npgsql +open Xunit + +/// Coverage: the `/feedback` flow, admin-only `/debug` and `/undo`, and the bot's short +/// command aliases (CommandHandler.fs's Dispatch, :390-499). +/// +/// Admin rights: FEEDBACK_ADMINS is seeded with the logged-in MTProto test user's own id +/// at fixture startup (RealAssemblyFixture.fs's InitializeAsync -> DbSeed.applyAsync, +/// RealAssemblyFixture.fs:157-165) — there is no COUPON_CI_FEEDBACK_ADMIN_ID and none +/// should be added. This also means every `/feedback` forward +/// (BotService.fs:129-132, `ForwardMessage.Make(adminId, msg.Chat.Id, msg.MessageId)` +/// with `adminId` = this account's own id) lands back in THIS SAME private chat +/// (fx.BotChatId) — that's what makes the forward observable with only one Telegram +/// account. +/// +/// GitHub issue creation (BotService.fs:135-145) is gated on GitHubService.IsConfigured +/// (GitHubService.fs:46-48), which requires a non-empty GITHUB_TOKEN — deliberately +/// absent from the CI test pod (contract). No test here adds a token or asserts on issue +/// creation; `/feedback` coverage below stops at the DB write + admin forward + +/// user-facing "Спасибо!" reply. +type AdminAndFeedbackRealTests(fx: RealAssemblyFixture) = + + /// `user_feedback` has no DbSeed.fs helper (adding one is out of this file's scope — + /// contract: "write it as a private function inside your own file" when a helper is + /// missing). Scoped by user_id + most recent row rather than telegram_message_id: + /// this suite drives exactly one MTProto account/user_id and runs serialized, so "our + /// own latest feedback row" is unambiguous without needing the MTProto message id <-> + /// Bot API message id correspondence. + let getLatestFeedbackTextAsync (userId: int64) : Task = + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + do! conn.OpenAsync() + return! + conn.QuerySingleAsync( + "SELECT feedback_text FROM user_feedback WHERE user_id = @user_id ORDER BY id DESC LIMIT 1", + {| user_id = userId |}) + } + + /// DB-level disarm of the pending `/feedback` flag — mirrors + /// DbService.ClearPendingFeedback's own SQL verbatim (`pending_feedback` table, + /// DbService.fs:858-863: "DELETE FROM pending_feedback WHERE user_id = @user_id"). + /// No DbSeed.fs helper exists for this yet (contract: "add it as a private function + /// inside your own file" when a shared helper is missing). Used only for test + /// cleanup below — never to assert on the flow itself. + /// + /// Exists specifically so that cleanup can be a plain, genuinely async DB delete + /// instead of a blocking Telegram round trip (review Fix 2): the previous cleanup + /// disarmed the flag by sending "/help" and blocking on + /// `.GetAwaiter().GetResult()` from inside a `task { }` body — a deadlock/latency + /// hazard (AGENTS.md: "never use `.Result`/`.Wait()`/`.GetAwaiter().GetResult()` — + /// they cause deadlocks"). This DB delete is exactly what "any command clears + /// pending feedback" (BotService.fs:89-91) does server-side anyway, without needing + /// a live Telegram call at all. + let clearPendingFeedbackAsync (userId: int64) : Task = + task { + use conn = new NpgsqlConnection(fx.DbConnectionString) + do! conn.OpenAsync() + let! _ = conn.ExecuteAsync("DELETE FROM pending_feedback WHERE user_id = @user_id", {| user_id = userId |}) + () + } + + /// Covers `/feedback` (CommandHandler.fs:434-436 -> handleFeedback :370-379): arms + /// the pending flag, the next non-command message is saved (DbService.SaveUserFeedback), + /// forwarded to every FeedbackAdminIds admin (here: this account itself), and answered + /// with the thank-you text. + [] + member _.``feedback happy path: DM saved, forwarded to admin (self), thanked``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + // Unique marker PER ATTEMPT — regenerated because this whole thunk re-runs on + // TestRetry's one retry, so a retried attempt's forward-search can never match + // attempt 1's already-forwarded copy (contract: "unique marker per attempt"). + let marker = $"real-test feedback marker {Guid.NewGuid():N}" + + let! armSentId = fx.UserClient.SendText(fx.BotChatId, "/feedback") + let! _armed = fx.UserClient.AwaitTextContaining(fx.BotChatId, armSentId, "Следующее твоё сообщение", TimeSpan.FromSeconds 60.) + + let! markerSentId = fx.UserClient.SendText(fx.BotChatId, marker) + + let! thanks = fx.UserClient.AwaitTextContaining(fx.BotChatId, markerSentId, "Спасибо! Сообщение отправлено авторам.", TimeSpan.FromSeconds 60.) + Assert.Contains("Спасибо!", thanks.message) + + // The admin forward (BotService.fs:129-132) is a SEPARATE message from the + // bot carrying the original marker text verbatim — distinguished from our own + // outgoing `marker` message by afterMsgId gating (m.id > markerSentId, and our + // own send's id is markerSentId itself, never greater than it). + let! forwarded = fx.UserClient.AwaitTextContaining(fx.BotChatId, markerSentId, marker, TimeSpan.FromSeconds 60.) + Assert.Contains(marker, forwarded.message) + + let! savedText = getLatestFeedbackTextAsync fx.UserClient.Me.id + Assert.Equal(marker, savedText) + }) + + /// Covers `/debug ` (CommandHandler.fs:480-484 -> handleDebug :40-45): admin-only, + /// dumps the coupon's full event-history table (BotHelpers.formatEventHistoryTable, + /// columns date/user/event_type — no coupon id column). Sets up history by adding then + /// taking the coupon, then asserts both event types show up in the dump. + [] + member _.``debug shows the coupon's event history``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-14_01-23_2706658654210.jpg" "10" "50" None + let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}") + let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.) + + let! debugSentId = fx.UserClient.SendText(fx.BotChatId, $"/debug {couponId}") + // sendCouponHistory (CommandHandler.fs:26-38) renders header=None for /debug, so + // the reply is just the
 table with no coupon id printed anywhere in it.
+            // "event_type" is the table's own column header (BotHelpers.fs:169) — an English
+            // DB-column name that never collides with Russian UI text — so it's a safe marker
+            // that this reply IS the history dump, not some other message racing in.
+            let! history = fx.UserClient.AwaitTextContaining(fx.BotChatId, debugSentId, "event_type", TimeSpan.FromSeconds 60.)
+            Assert.Contains("added", history.message)
+            Assert.Contains("taken", history.message)
+
+            // Cleanup, not assertion: this test's own point (the history dump) is proven
+            // above. Left 'taken' forever, this coupon would permanently eat one of this
+            // account's shared MAX_TAKEN_COUPONS=6 slots for the rest of the serial run —
+            // same leak DbSeed.releaseTakenCouponAsync's doc comment describes; this test
+            // took a coupon but never released/used/returned it before this fix.
+            do! DbSeed.releaseTakenCouponAsync fx.DbConnectionString couponId
+        })
+
+    /// Covers `/undo ` (CommandHandler.fs:485-489 -> handleUndo :49-77). Sets up a
+    /// coupon whose last live event is well-defined (add then take), so UndoLastEvent
+    /// (DbService.fs:1010-1117) rewinds the "taken" event specifically
+    /// (DbService.fs:1078-1081: status 'taken' -> 'available', taken_by/taken_at cleared —
+    /// "back to the common pool", no prior holder restored). Asserts the confirmation text
+    /// and the DB rollback via DbSeed.getCouponStatusAsync.
+    []
+    member _.``undo rewinds the last live event (taken -> available)``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-19_01-28_2706613152454.jpg" "10" "50" None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! undoSentId = fx.UserClient.SendText(fx.BotChatId, $"/undo {couponId}")
+            // handleUndo's header (CommandHandler.fs:71-74) renders as
+            // "Откат купона ID:: taken → available.\n...", which the coupon id makes
+            // unique per attempt/coupon.
+            let! undone = fx.UserClient.AwaitTextContaining(fx.BotChatId, undoSentId, $"Откат купона ID:{couponId}", TimeSpan.FromSeconds 60.)
+            Assert.Contains("taken", undone.message)
+            Assert.Contains("available", undone.message)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("available", status.status)
+            Assert.False(status.taken_by.HasValue, "Expected taken_by to be cleared by the undo")
+        })
+
+    /// Covers the short aliases confirmed against CommandHandler.fs's Dispatch
+    /// (:390-499): /l, /coupons, bare /take (-> handleCoupons, :404-415); /m (-> handleMy,
+    /// :416-421); /ad (-> handleAdded, :422-427); /s (-> handleStats, :428-433); /a (->
+    /// HandleAddWizardStart, :440-445); /f (-> handleFeedback, :434-439). No further
+    /// aliases exist in Dispatch beyond these.
+    ///
+    /// Kept cheap: one command per alias, tight per-command timeout, state-independent
+    /// markers wherever the canonical reply has one (so this test doesn't depend on what
+    /// earlier tests/runs left behind), and explicit, deliberate cleanup of both stateful
+    /// aliases (/a's add-wizard row, /f's pending-feedback flag) so this test can never
+    /// corrupt a later test class in the same serial run (contract, "Isolation model").
+    ///
+    /// Cleanup shape (review Fix 2): F#'s `task { }` computation expression cannot have a
+    /// `let!`/`do!` inside a `try/finally`'s `finally` block, and the previous cleanup
+    /// blocked on `.GetAwaiter().GetResult()` from inside this async body — a
+    /// deadlock/latency hazard (AGENTS.md: never use `.Result`/`.Wait()`/
+    /// `.GetAwaiter().GetResult()`, they cause deadlocks in ASP.NET Core; the same
+    /// applies to any async F# body). Restructured as `try/with` + explicit re-raise
+    /// instead: the happy path runs `cleanupAsync()` inline at the end of `try`, and the
+    /// `with` handler re-runs the SAME (idempotent) cleanup before re-raising — so the
+    /// pending-feedback flag and the add-wizard row are disarmed on every exit path,
+    /// including an assertion throwing partway through, with no blocking wait anywhere.
+    []
+    member _.``short aliases behave as their canonical commands``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let cleanupAsync () : Task =
+                task {
+                    do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString fx.UserClient.Me.id
+                    do! clearPendingFeedbackAsync fx.UserClient.Me.id
+                }
+
+            try
+                // /l, /coupons, bare /take all alias handleCoupons (CommandHandler.fs:404-415).
+                // "Всего доступно купонов:" (CommandHandler.fs:93) is sent on both the
+                // empty and non-empty branches, so it's a state-independent marker.
+                for cmd in [ "/l"; "/coupons"; "/take" ] do
+                    let! sentId = fx.UserClient.SendText(fx.BotChatId, cmd)
+                    let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Всего доступно купонов:", TimeSpan.FromSeconds 30.)
+                    Assert.Contains("Всего доступно купонов:", reply.message)
+
+                // /m -> handleMy (CommandHandler.fs:419-421). "Мои купоны" is the taken-
+                // coupons section's own fixed leading text (CommandHandler.fs:210,221),
+                // present whether or not this account currently holds any coupon.
+                let! mSentId = fx.UserClient.SendText(fx.BotChatId, "/m")
+                let! mReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, mSentId, "Мои купоны", TimeSpan.FromSeconds 30.)
+                Assert.Contains("Мои купоны", mReply.message)
+
+                // /ad -> handleAdded (CommandHandler.fs:425-427). Unlike /my, handleAdded's
+                // empty and non-empty replies share no common substring
+                // ("У тебя нет активных добавленных купонов." vs "Мои добавленные
+                // купоны:") — add a coupon first to force the non-empty branch
+                // deterministically instead of depending on leftover state.
+                let! addedCouponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-21_01-30_2706616470579.jpg" "10" "50" None
+                let! adSentId = fx.UserClient.SendText(fx.BotChatId, "/ad")
+                let! adReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, adSentId, "Мои добавленные купоны:", TimeSpan.FromSeconds 30.)
+                Assert.Contains($"ID:{addedCouponId}", adReply.message)
+
+                // /s -> handleStats (CommandHandler.fs:431-433). "Статистика:" is the
+                // reply's fixed leading text (CommandHandler.fs:162-163).
+                let! sSentId = fx.UserClient.SendText(fx.BotChatId, "/s")
+                let! sReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sSentId, "Статистика:", TimeSpan.FromSeconds 30.)
+                Assert.Contains("Статистика:", sReply.message)
+
+                // /a -> HandleAddWizardStart (CommandHandler.fs:443-445), which arms
+                // stage='awaiting_photo' in `pending_add`. Cleared explicitly right away
+                // (contract: "clear it with DbSeed.deletePendingAddFlowAsync, or cancel the
+                // flow") rather than relying on the /f command right after to also clear it
+                // as a side effect (it would — BotService.fs:92-94, "any command except
+                // /add cancels add wizard" — but being explicit here doesn't depend on
+                // alias-ordering staying the same later).
+                let! aSentId = fx.UserClient.SendText(fx.BotChatId, "/a")
+                let! aReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, aSentId, "Пришли фото купона", TimeSpan.FromSeconds 30.)
+                Assert.Contains("Пришли фото купона", aReply.message)
+                do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString fx.UserClient.Me.id
+
+                // /f -> handleFeedback (CommandHandler.fs:437-439), which arms
+                // SetPendingFeedback. Full /feedback coverage (forward, DB write,
+                // thank-you) lives in its own test above; here we only confirm the alias
+                // reaches handleFeedback — `cleanupAsync` below disarms it on every exit path.
+                let! fSentId = fx.UserClient.SendText(fx.BotChatId, "/f")
+                let! fReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, fSentId, "Следующее твоё сообщение", TimeSpan.FromSeconds 30.)
+                Assert.Contains("Следующее твоё сообщение", fReply.message)
+
+                do! cleanupAsync ()
+            with ex ->
+                // Guarantees the pending-feedback flag and the add-wizard row are
+                // disarmed even when an assertion above threw. `raise ex` rather than
+                // `reraise()`: this handler awaits (`do!`) before re-throwing, and
+                // `reraise()` is only valid synchronously inside the original handler
+                // frame — it would not survive the `await` in a task CE's desugaring.
+                do! cleanupAsync ()
+                raise ex
+        })
diff --git a/tests/CouponHubBot.RealTests/BulkAddRealTests.fs b/tests/CouponHubBot.RealTests/BulkAddRealTests.fs
index 8f2cb95..236ba76 100644
--- a/tests/CouponHubBot.RealTests/BulkAddRealTests.fs
+++ b/tests/CouponHubBot.RealTests/BulkAddRealTests.fs
@@ -42,11 +42,12 @@ open Xunit
 /// booted for /test/clock/advance to work at all.
 type BulkAddRealTests(fx: RealAssemblyFixture) =
 
-    let images =
+    let imageFileNames =
         [ "10_50_2026-01-17_2026-01-26_2706688198821.jpg"
           "10_50_2026-01-17_2026-01-26_2706688198838.jpg"
           "10_50_2026-01-17_2026-01-26_2706688198845.jpg" ]
-        |> List.map (fun f -> Path.Combine(RealEnv.ocrFixtureImagesDir, f))
+
+    let images = imageFileNames |> List.map (fun f -> Path.Combine(RealEnv.ocrFixtureImagesDir, f))
 
     let pollInterval = TimeSpan.FromMilliseconds 500.
 
@@ -132,6 +133,16 @@ type BulkAddRealTests(fx: RealAssemblyFixture) =
             fx.SkipUnlessUserClient()
             images |> List.iter (fun p -> Assert.True(File.Exists p, $"Expired-coupon fixture missing: {p}"))
 
+            // Idempotence: delete any pre-existing row for each fixture's barcode FIRST —
+            // this is the album-flow equivalent of RealTestHelpers.addCouponViaCaptionAsync's
+            // own delete-first step. Without this, a leftover 'available' coupon from an
+            // earlier run of THIS test (via TestRetry's one retry, if the confirm succeeded
+            // but a later await in this same attempt timed out) would make the
+            // addflow:bulk:confirm below silently skip that item as a duplicate instead of
+            // landing all 3 — see README.md's "Fixture reuse rule" section.
+            for f in imageFileNames do
+                do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString RealTestHelpers.fixtureBarcodes.[f]
+
             let ownerId = fx.UserClient.Me.id
             let! countBefore = countCouponsForOwner ownerId
 
diff --git a/tests/CouponHubBot.RealTests/CouponHubBot.RealTests.fsproj b/tests/CouponHubBot.RealTests/CouponHubBot.RealTests.fsproj
index c2799ff..d772e02 100644
--- a/tests/CouponHubBot.RealTests/CouponHubBot.RealTests.fsproj
+++ b/tests/CouponHubBot.RealTests/CouponHubBot.RealTests.fsproj
@@ -15,6 +15,10 @@
     
     
     
+    
+    
     
+    
+    
+    
+    
     
     
diff --git a/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs b/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
index fdce529..68b76e2 100644
--- a/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
+++ b/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
@@ -1,9 +1,6 @@
 namespace CouponHubBot.RealTests
 
 open System
-open System.Globalization
-open System.IO
-open System.Threading.Tasks
 open Dapper
 open Npgsql
 open Xunit
@@ -26,35 +23,12 @@ open Xunit
 /// single coupon.
 type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
 
-    let futureExpiry = DateTime.UtcNow.AddDays(200.).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
-
-    let latestCouponIdForOwner (ownerId: int64) =
-        task {
-            use conn = new NpgsqlConnection(fx.DbConnectionString)
-            return! conn.QuerySingleAsync("SELECT id FROM coupon WHERE owner_id=@o ORDER BY id DESC LIMIT 1", {| o = ownerId |})
-        }
-
-    /// Adds a coupon via photo + explicit "/add   "
-    /// caption (same reasoning as AddFlowRealTests: OCR still runs server-side, but the
-    /// caption's explicit values are what the app actually uses — see AddFlowRealTests'
-    /// doc comment for why a bare "/add" + OCR-derived expiry can't be used against an
-    /// intentionally-expired fixture image). Returns the new coupon's id.
-    let addCoupon (fixtureFileName: string) (value: string) (minCheck: string) =
-        task {
-            let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, fixtureFileName)
-            Assert.True(File.Exists imagePath, $"Expired-coupon fixture missing: {imagePath}")
-
-            let! sentId = fx.UserClient.SendPhoto(fx.BotChatId, imagePath, $"/add {value} {minCheck} {futureExpiry}")
-            let! _reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Добавлен купон", TimeSpan.FromSeconds 90.)
-            return! latestCouponIdForOwner fx.UserClient.Me.id
-        }
-
     []
     member _.``list shows the coupon and its take callback takes it``() =
         TestRetry.withTimeoutRetry (fun () -> task {
             fx.SkipUnlessUserClient()
 
-            let! couponId = addCoupon "10_50_01-04_01-13_2706602781191.jpg" "10" "50"
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-04_01-13_2706602781191.jpg" "10" "50" None
 
             let! sentId = fx.UserClient.SendText(fx.BotChatId, "/list")
             let! listMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, $"ID:{couponId}", TimeSpan.FromSeconds 60.)
@@ -66,6 +40,13 @@ type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
             let! takenMsg =
                 fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, listMsg.id, $"Купон ID:{couponId} теперь твой", TimeSpan.FromSeconds 60.)
             Assert.Contains("теперь твой", takenMsg.message)
+
+            // Cleanup, not assertion: this test's own point is proven above. Left 'taken'
+            // forever, this coupon would permanently eat one of this account's shared
+            // MAX_TAKEN_COUPONS=6 slots for the rest of the serial run — the root cause of
+            // AdminAndFeedbackRealTests.fs's "undo" test hitting `LimitReached` on its own
+            // /take in run 30181924500 (see DbSeed.releaseTakenCouponAsync's doc comment).
+            do! DbSeed.releaseTakenCouponAsync fx.DbConnectionString couponId
         })
 
     []
@@ -73,7 +54,7 @@ type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
         TestRetry.withTimeoutRetry (fun () -> task {
             fx.SkipUnlessUserClient()
 
-            let! couponId = addCoupon "10_50_01-06_01-15_2706643333717.jpg" "12" "60"
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-06_01-15_2706643333717.jpg" "12" "60" None
             let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
             let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
 
@@ -98,6 +79,9 @@ type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
             let hasUsed = fx.UserClient.FindCallbackData(myMsg, fun d -> d = $"used:{couponId}")
             Assert.True(hasReturn.IsSome, $"Expected a return:{couponId} button under /my")
             Assert.True(hasUsed.IsSome, $"Expected a used:{couponId} button under /my")
+
+            // Cleanup, not assertion — see the sibling test above's identical comment.
+            do! DbSeed.releaseTakenCouponAsync fx.DbConnectionString couponId
         })
 
     []
@@ -105,7 +89,7 @@ type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
         TestRetry.withTimeoutRetry (fun () -> task {
             fx.SkipUnlessUserClient()
 
-            let! couponId = addCoupon "10_50_01-12_01-21_2706513420233.jpg" "10" "50"
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-12_01-21_2706513420233.jpg" "10" "50" None
             let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
             let! takenMsg = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
 
@@ -134,7 +118,7 @@ type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
         TestRetry.withTimeoutRetry (fun () -> task {
             fx.SkipUnlessUserClient()
 
-            let! couponId = addCoupon "10_50_01-12_01-21_2706530490622.jpg" "10" "50"
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-12_01-21_2706530490622.jpg" "10" "50" None
             let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
             let! takenMsg = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
 
diff --git a/tests/CouponHubBot.RealTests/DbSeed.fs b/tests/CouponHubBot.RealTests/DbSeed.fs
index c6b2489..ae426c4 100644
--- a/tests/CouponHubBot.RealTests/DbSeed.fs
+++ b/tests/CouponHubBot.RealTests/DbSeed.fs
@@ -8,6 +8,7 @@
 /// module only applies/refreshes rows in an already-reachable, already-migrated database.
 module CouponHubBot.RealTests.DbSeed
 
+open System
 open System.Threading.Tasks
 open Dapper
 open Npgsql
@@ -86,6 +87,156 @@ let truncateBatchesAsync (connectionString: string) : Task =
         ()
     }
 
+/// Status + holder columns for one coupon id — the shape every take/used/return/report
+/// real test's final DB assertion needs. `owner_id`/`taken_by` are exposed too (rather
+/// than just `status`) so a caller can assert who currently holds it without a second
+/// query — `taken_by` is nullable in the schema (coupon.taken_by BIGINT NULL), hence
+/// `Nullable` per the src/CouponHubBot/Services/DbService.fs convention for the
+/// same column, not `int64 option` (AGENTS.md's Dapper-nullable-column rule technically
+/// only calls out `string | null`, but `coupon.taken_by`'s existing F# mapping already
+/// sets the precedent for this column specifically).
+[]
+type CouponStatusRow =
+    { status: string
+      owner_id: int64
+      taken_by: Nullable }
+
+/// Coupon status/owner/taken_by by id — used by every take/used/return/report/void real
+/// test's terminal-state DB assertion (`SELECT status FROM coupon WHERE id=@id` is
+/// currently copy-pasted inline in CouponLifecycleRealTests.fs and ReportFlowRealTests.fs;
+/// this gives that query one home and adds owner_id/taken_by for the tests that need them).
+let getCouponStatusAsync (connectionString: string) (couponId: int) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        return!
+            conn.QuerySingleAsync(
+                "SELECT status, owner_id, taken_by FROM coupon WHERE id = @coupon_id",
+                {| coupon_id = couponId |})
+    }
+
+/// Newest coupon id owned by `ownerId` — the "SELECT id FROM coupon WHERE owner_id=@o
+/// ORDER BY id DESC LIMIT 1" pattern copy-pasted inline in AddFlowRealTests.fs,
+/// CouponLifecycleRealTests.fs and ReportFlowRealTests.fs, given one home. Relies on the
+/// same single-account/no-cross-test-ordering assumptions those call sites already
+/// document — call it immediately after the add that's supposed to have produced the
+/// coupon, before any other add for the same owner can land.
+let getLatestOwnedCouponIdAsync (connectionString: string) (ownerId: int64) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        return!
+            conn.QuerySingleAsync(
+                "SELECT id FROM coupon WHERE owner_id = @owner_id ORDER BY id DESC LIMIT 1",
+                {| owner_id = ownerId |})
+    }
+
+/// Overwrites `expires_at` for one coupon id via direct SQL — BulkAddRealTests.fs's
+/// `bumpItemsToFutureExpiry` already does the pending_add_batch_item equivalent of this
+/// inline (it needs to override OCR's own past-dated expiry before TryAddCoupon's
+/// `expires_at < todayUtc()` gate runs); the interactive wizard tests need the same trick
+/// against the `coupon` table itself once a coupon already exists (e.g. to manufacture an
+/// expiring-today/expired coupon for a status-dependent flow) rather than pending_add_batch_item.
+let setCouponExpiryAsync (connectionString: string) (couponId: int) (expiresAt: DateOnly) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        let! _ =
+            conn.ExecuteAsync(
+                "UPDATE coupon SET expires_at = @expires_at WHERE id = @coupon_id",
+                {| coupon_id = couponId; expires_at = expiresAt |})
+        ()
+    }
+
+/// Deletes every coupon row for a given `barcode_text` (all expiries, all statuses) —
+/// THE helper that makes /add fixture-reuse safe (contract, "problem" section 1+2).
+/// `coupon_event.coupon_id REFERENCES coupon(id) ON DELETE CASCADE` (V1__initial.sql) is
+/// the only FK pointing at `coupon` anywhere in migrations/ (checked: `grep -rl
+/// "REFERENCES coupon"` finds only V1__initial.sql, which defines that one cascade) — no
+/// extra child-table deletes are needed; a plain DELETE on `coupon` cascades automatically.
+/// Call this BEFORE sending a fixture photo, not after: it's what makes a retried /add
+/// attempt (TestRetry's one automatic retry, after an AwaitTimeoutException on attempt 1)
+/// safe even if attempt 1's coupon actually landed — and what lets the same fixture image
+/// be reused by many different tests instead of needing one distinct barcode per test.
+let deleteCouponsByBarcodeAsync (connectionString: string) (barcodeText: string) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        let! _ = conn.ExecuteAsync("DELETE FROM coupon WHERE barcode_text = @barcode_text", {| barcode_text = barcodeText |})
+        ()
+    }
+
+/// Clears a user's in-flight interactive `/add` wizard state (the `pending_add` table —
+/// mapped in src/CouponHubBot/Services/DbService.fs as the `PendingAddFlow` record;
+/// there is no separate "PendingAddFlow table", it's `pending_add`, checked against
+/// V6__recreate_pending_add.sql and DbService.fs's own ClearPendingAddFlow: "DELETE FROM
+/// pending_add WHERE user_id = @user_id"). Lets a wizard test guarantee a clean starting
+/// stage regardless of what a previous test — or a previous TestRetry attempt of the SAME
+/// test — left behind; GetPendingAddFlow's own 1-hour staleness expiry
+/// (DbService.fs:758-770) is too coarse-grained to rely on between fast-running tests.
+let deletePendingAddFlowAsync (connectionString: string) (userId: int64) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        let! _ = conn.ExecuteAsync("DELETE FROM pending_add WHERE user_id = @user_id", {| user_id = userId |})
+        ()
+    }
+
+/// Clears a user's in-flight album/bulk-add batches (`pending_add_batch`, all statuses —
+/// V16__pending_add_batch.sql). `pending_add_batch_item.batch_id REFERENCES
+/// pending_add_batch(id) ON DELETE CASCADE`, so deleting the parent row is enough; no
+/// separate pending_add_batch_item delete needed. Lets an album/bulk-add wizard test
+/// guarantee no stale batch survives from a previous test or retry attempt — BulkAddRealTests
+/// already relies on `pending_add_batch_user_mgid_active_uniq` (one active batch per
+/// (user_id, media_group_id) while status IN ('open','awaiting_user')) never colliding
+/// with a leftover row from an earlier run; this is that cleanup, given one home.
+let deletePendingBatchesAsync (connectionString: string) (userId: int64) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        let! _ = conn.ExecuteAsync("DELETE FROM pending_add_batch WHERE user_id = @user_id", {| user_id = userId |})
+        ()
+    }
+
+/// Direct-SQL release of a taken coupon back to 'available' (status/taken_by/taken_at
+/// cleared) — no live Telegram round trip needed. Cleanup-only: for a test whose own
+/// assertions are already done with a coupon it took, so it stops permanently consuming
+/// one of this account's MAX_TAKEN_COUPONS=6 slots (bot_setting seed above) for the rest
+/// of a serial run shared with every other real test. Root-caused against run 30181924500:
+/// AdminAndFeedbackRealTests.fs's "undo" test's own `/take` got DbService.TryTakeCoupon's
+/// `LimitReached` branch (DbService.fs:512-514) — a plain text reply, never a photo
+/// caption — because enough EARLIER tests' leftover 'taken' coupons (never returned/used)
+/// had already filled the 6-slot budget for this single shared MTProto account.
+let releaseTakenCouponAsync (connectionString: string) (couponId: int) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        let! _ =
+            conn.ExecuteAsync(
+                "UPDATE coupon SET status = 'available', taken_by = NULL, taken_at = NULL WHERE id = @coupon_id",
+                {| coupon_id = couponId |})
+        ()
+    }
+
+/// Deletes one coupon row by id — used only by ReminderRealTests.fs to remove its own
+/// direct-SQL-inserted synthetic coupon (a fake, non-Telegram `photo_file_id`, see that
+/// file's doc comment) once its assertion is done. That row has no `barcode_text`
+/// (`deleteCouponsByBarcodeAsync` can never find/clean it) and, left behind as 'taken',
+/// both permanently consumes one of this account's MAX_TAKEN_COUPONS slots AND poisons
+/// every later `/my` for the rest of the run — handleMy (CommandHandler.fs:196-199) sends
+/// a photo/media-group built from every 'taken' coupon's `photo_file_id`, and Telegram
+/// rejects the whole request with "wrong remote file identifier specified: Wrong padding
+/// in the string" the moment the synthetic one is included (confirmed against run
+/// 30181924500's bot pod logs, UpdateId 84308640/84308644, both landing on
+/// CommandHandler.fs:199 per the unhandled-error stack trace).
+let deleteCouponByIdAsync (connectionString: string) (couponId: int) : Task =
+    task {
+        use conn = new NpgsqlConnection(connectionString)
+        do! conn.OpenAsync()
+        let! _ = conn.ExecuteAsync("DELETE FROM coupon WHERE id = @coupon_id", {| coupon_id = couponId |})
+        ()
+    }
+
 /// Current value of a bot_setting row — used by MembershipGateRealTests to save/restore
 /// COMMUNITY_CHAT_ID around its DB-driven "point the gate at a chat this user isn't in" trick.
 let getSettingAsync (connectionString: string) (key: string) : Task =
diff --git a/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs b/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs
new file mode 100644
index 0000000..6be0a3a
--- /dev/null
+++ b/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs
@@ -0,0 +1,186 @@
+namespace CouponHubBot.RealTests
+
+open System
+open Xunit
+
+/// Coverage: `/added` + its bare `void:` button, the `myAdded` button (which renders
+/// the same as `/added`, CallbackHandler.fs:429-431), and the text-command / bare-button
+/// variants of take/used/return that CouponLifecycleRealTests.fs does NOT cover (that file
+/// owns the `:del`-suffixed take-confirmation-card buttons and the plain `/list` + `take:`
+/// flow — see its own doc comment).
+///
+/// Bare vs `:del` disambiguation: CallbackHandler.fs's "used:"/"return:"/"void:" branches
+/// (CallbackHandler.fs:346-381) all match by `StartsWith`, so both the bare `used:`
+/// (rendered under `/my`, CommandHandler.fs:247-248) and the `:del`-suffixed
+/// `used::del` (rendered on the take-confirmation card by BotHelpers.singleTakenKeyboard)
+/// are valid presses of the SAME handler — only the trailing message-delete differs. Every
+/// button lookup below therefore uses an EXACT match (`d = $"used:{couponId}"`, never
+/// `d.StartsWith`) so it can never accidentally grab the `:del` sibling when both are
+/// present on the same message (they are not, in practice — `/my` and the take card are
+/// different messages — but an exact predicate makes that invariant load-bearing rather
+/// than incidental).
+///
+/// Single shared MTProto account owns, takes and voids every coupon here (see
+/// MembershipGateRealTests' doc comment) — DbService.TryTakeCoupon has no self-take
+/// restriction and CommandHandler.fs:307-332's void path allows a non-admin to void their
+/// OWN coupon, so no second account/admin setup is needed. Each test adds its own coupon
+/// via a distinct fixture (no cross-test chaining — xUnit does not guarantee ordering).
+type MyAndAddedRealTests(fx: RealAssemblyFixture) =
+
+    []
+    /// `/added` lists an active added coupon and its bare `void:` button
+    /// (CommandHandler.fs:267-305 / :301) voids it — confirmed via
+    /// DbSeed.getCouponStatusAsync against the `voided` status CommandHandler.fs's
+    /// handleVoid/DbService.VoidCoupon writes (DbService.fs:910).
+    member _.``added lists the coupon and its void button voids it``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-14_01-23_2706658654210.jpg" "10" "50" None
+
+            let! addedSentId = fx.UserClient.SendText(fx.BotChatId, "/added")
+            let! addedMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, addedSentId, $"ID:{couponId}", TimeSpan.FromSeconds 60.)
+
+            let voidData = fx.UserClient.FindCallbackData(addedMsg, fun d -> d = $"void:{couponId}")
+            Assert.True(voidData.IsSome, $"Expected a void:{couponId} button on the /added message")
+
+            do! fx.UserClient.PressCallbackButton(fx.BotChatId, addedMsg.id, voidData.Value)
+            let! _voidedReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, addedMsg.id, $"Купон ID:{couponId} аннулирован", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("voided", status.status)
+        })
+
+    []
+    /// The `myAdded` button on `/my`'s bottom row (CommandHandler.fs:258) renders the same
+    /// list `/added` does (CallbackHandler.fs:429-431 -> HandleAdded). Adds AND takes a
+    /// coupon first so `/my` has a taken-coupon section to render (the myAdded button
+    /// itself is unconditional, but this exercises the realistic non-empty state).
+    member _.``myAdded button on /my renders the added-coupons list``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-19_01-28_2706613152454.jpg" "10" "50" None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! mySentId = fx.UserClient.SendText(fx.BotChatId, "/my")
+            let! myMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, mySentId, $"Купон ID:{couponId}", TimeSpan.FromSeconds 60.)
+
+            do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, myMsg, (fun d -> d = "myAdded"), "myAdded")
+            let! addedMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, "Мои добавленные купоны", TimeSpan.FromSeconds 60.)
+            Assert.Contains($"ID:{couponId}", addedMsg.message)
+
+            // Cleanup, not assertion: this test's own point is proven above. Left 'taken'
+            // forever, this coupon would permanently eat one of this account's shared
+            // MAX_TAKEN_COUPONS=6 slots for the rest of the serial run — see
+            // DbSeed.releaseTakenCouponAsync's doc comment.
+            do! DbSeed.releaseTakenCouponAsync fx.DbConnectionString couponId
+        })
+
+    []
+    /// The BARE `used:` button under `/my` (CommandHandler.fs:247-248, distinct from
+    /// the `:del`-suffixed take-confirmation-card button CouponLifecycleRealTests.fs
+    /// covers) marks the coupon used and does NOT delete the `/my` message afterwards
+    /// (CallbackHandler.fs:359-371 only deletes when `data.EndsWith(":del")`).
+    member _.``bare used button under /my marks the coupon used``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-21_01-30_2706616470579.jpg" "10" "50" None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! mySentId = fx.UserClient.SendText(fx.BotChatId, "/my")
+            let! myMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, mySentId, $"Купон ID:{couponId}", TimeSpan.FromSeconds 60.)
+
+            let usedData = fx.UserClient.FindCallbackData(myMsg, fun d -> d = $"used:{couponId}")
+            Assert.True(usedData.IsSome, $"Expected a bare used:{couponId} button under /my")
+
+            do! fx.UserClient.PressCallbackButton(fx.BotChatId, myMsg.id, usedData.Value)
+            let! _usedReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, $"Купон ID:{couponId} отмечен", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("used", status.status)
+        })
+
+    []
+    /// The BARE `return:` button under `/my` (CommandHandler.fs:247-248, distinct from
+    /// the `:del`-suffixed take-confirmation-card button) returns the coupon to the
+    /// available pool.
+    member _.``bare return button under /my returns the coupon to available``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "5_25_01-15_01-21_2706528422291.jpg" "5" "25" None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! mySentId = fx.UserClient.SendText(fx.BotChatId, "/my")
+            let! myMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, mySentId, $"Купон ID:{couponId}", TimeSpan.FromSeconds 60.)
+
+            let returnData = fx.UserClient.FindCallbackData(myMsg, fun d -> d = $"return:{couponId}")
+            Assert.True(returnData.IsSome, $"Expected a bare return:{couponId} button under /my")
+
+            do! fx.UserClient.PressCallbackButton(fx.BotChatId, myMsg.id, returnData.Value)
+            let! _returnedReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, $"Купон ID:{couponId} возвращён", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("available", status.status)
+        })
+
+    []
+    /// Text command `/used ` (CommandHandler.fs:451-457 -> handleUsed,
+    /// CommandHandler.fs:127-135) marks the coupon used.
+    member _.``text command /used marks the coupon used``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "5_25_01-15_01-21_2706666377231.jpg" "5" "25" None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! usedSentId = fx.UserClient.SendText(fx.BotChatId, $"/used {couponId}")
+            let! _usedReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, usedSentId, $"Купон ID:{couponId} отмечен", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("used", status.status)
+        })
+
+    []
+    /// Text command `/return ` (CommandHandler.fs:458-464 -> handleReturn,
+    /// CommandHandler.fs:137-145) returns the coupon to the available pool.
+    member _.``text command /return returns the coupon to available``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "5_25_01-15_01-21_2706684806638.jpg" "5" "25" None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! returnSentId = fx.UserClient.SendText(fx.BotChatId, $"/return {couponId}")
+            let! _returnedReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, returnSentId, $"Купон ID:{couponId} возвращён", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("available", status.status)
+        })
+
+    []
+    /// Text command `/void ` (CommandHandler.fs:468-474 -> handleVoid,
+    /// CommandHandler.fs:307-332) voids an added-but-not-taken coupon. Non-admins CAN
+    /// void their own coupon (CommandHandler.fs:307-332 computes `isAdmin` only to decide
+    /// whether to log an "admin voided someone else's coupon" line; the DB-level authz is
+    /// `db.VoidCoupon(couponId, user.id, isAdmin)`, and this account is both owner and
+    /// caller regardless of admin status).
+    member _.``text command /void voids an added coupon``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "5_25_2026-02-08_2026-02-14_2706726228947.jpg" "5" "25" None
+
+            let! voidSentId = fx.UserClient.SendText(fx.BotChatId, $"/void {couponId}")
+            let! _voidedReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, voidSentId, $"Купон ID:{couponId} аннулирован", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("voided", status.status)
+        })
diff --git a/tests/CouponHubBot.RealTests/README.md b/tests/CouponHubBot.RealTests/README.md
new file mode 100644
index 0000000..f4f6047
--- /dev/null
+++ b/tests/CouponHubBot.RealTests/README.md
@@ -0,0 +1,170 @@
+# CouponHubBot.RealTests — authoring guide
+
+Real-Telegram (MTProto), real-DB tests for CouponHubBot. **CI-dispatch only** — this
+suite talks to a real Telegram account and a real bot deployment, costs money, and can
+post to real Telegram. Never run it locally or from an agent; the CI workflow that
+dispatches it is the only sanctioned runner. Local `dotnet build` (not `dotnet test`) is
+fine and is how you validate a new file compiles.
+
+## RULE: every coupon add MUST be idempotent
+
+The suite runs SERIALLY over ONE shared transient Postgres and ONE Telegram account, with
+no per-test cleanup by default, and **test class execution order is NOT guaranteed** (not
+`.fsproj` order — proven empirically by CI run `30193635437`, which failed 4/32 tests
+this way). Combined with `coupon_barcode_active_uniq` (the partial unique index `/add`'s
+OCR-extracted barcode is checked against since merge #269), this means: if ANY two
+coupon-adds anywhere in this assembly can use the same fixture's barcode, and one of them
+doesn't delete-first, then whichever one runs SECOND — in whatever order THIS run happens
+to pick — gets rejected with «Купон с таким штрихкодом уже есть в базе…» instead of
+«Добавлен купон ID:», and its `Await*` call times out waiting for text that will never
+arrive.
+
+**"The other test always runs first" is not a safety argument.** There is no guaranteed
+order, so any reasoning of that shape is wrong by construction — this is exactly the bug
+this rule exists to close. The only correct fix is that every add is safe regardless of
+what ran before it, including a retried attempt of itself (see "Retry contract" below).
+
+Two sanctioned patterns, pick whichever fits the test:
+1. **Default**: route the add through `RealTestHelpers.addCouponViaCaptionAsync`, which
+   deletes any pre-existing row for the fixture's barcode FIRST, then sends the photo.
+   Use this whenever the add is just setup for something else under test.
+2. **Bespoke add** (the test's own subject IS the add reply, or it inserts via raw SQL):
+   call `DbSeed.deleteCouponsByBarcodeAsync` yourself, immediately before the add, and
+   keep asserting on the add's own reply/DB row as before. If the row has no barcode
+   (`NULL`/synthetic `barcode_text`, e.g. a direct SQL insert), you cannot clean up by
+   barcode — instead delete the row by id (`DbSeed.deleteCouponByIdAsync`) once your
+   assertions are done, on BOTH the success and the failure path (`try`/`with` +
+   re-raise, since F#'s `task { }` can't `do!`/`let!` inside `finally`).
+
+Never hand-roll `SendPhoto` + an `/add` caption (or a raw `INSERT INTO coupon`) without
+one of the two patterns above — that reintroduces the exact flake this rule exists to
+close, even if the fixture "looks" unshared today; a later PR reusing the same fixture
+elsewhere in this assembly won't know that assumption existed.
+
+## New-test skeleton
+
+```fsharp
+namespace CouponHubBot.RealTests
+
+open System
+open Xunit
+
+type MyNewRealTests(fx: RealAssemblyFixture) =
+
+    []
+    member _.``some behavior, in plain English``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-04_01-13_2706602781191.jpg" "10" "50" None
+
+            let! sentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _reply = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, sentId, "теперь твой", TimeSpan.FromSeconds 60.)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("taken", status.status)
+        })
+```
+
+Every `[]` body is the sole content of one `TestRetry.withTimeoutRetry` thunk, and
+starts with `fx.SkipUnlessUserClient()`.
+
+## Retry contract — why assertion failures are NOT retried
+
+`TestRetry.withTimeoutRetry` (`TestRetry.fs`) retries the whole test body **exactly
+once**, and **only** when it raises `AwaitTimeoutException` (`TgUserClient.fs`) — the
+"no message matching X within Ns" family. `Assert.*` failures, and any `failwith`, are a
+different, non-flaky failure mode and propagate uncaught on the first attempt. This is
+deliberate: retrying a real assertion failure would silently hide a real bug behind a
+second, expensive, real-Telegram round trip.
+
+Consequence for helper authors and test authors alike: **be deliberate about which
+exception type a failure is.** "The bot didn't reply in time" -> `AwaitTimeoutException`
+(retryable). "The bot replied, but not with what we expected" (wrong text, missing
+button, wrong DB state) -> `Assert.*` / plain exception (not retryable). Never make an
+assertion-class failure throw `AwaitTimeoutException` just to get a free retry, and never
+wrap a genuinely await-shaped helper in something that swallows `AwaitTimeoutException`
+into a different exception type — either mistake defeats `TestRetry`'s whole design.
+
+Because retry means **re-running the whole body**, a test that adds a coupon inside a
+retried body will try to add the SAME fixture photo twice if attempt 1 actually succeeded
+before timing out. That is exactly the problem `addCouponViaCaptionAsync` /
+`deleteCouponsByBarcodeAsync` solve — see below.
+
+## Fixture reuse rule + the public-repo constraint
+
+Photo fixtures live in `tests/CouponHubBot.Ocr.Tests/Images/` — reuse them, **do not add
+new ones**. This repo is public; a fixture with a live, currently-usable barcode would
+publish that barcode. Every fixture in that folder is deliberately dated in the past, so
+every `/add` through one must carry an explicit future date in its caption (`/add 
+ `) — OCR reads the barcode from the photo, but the app uses the
+caption's explicit value/min-check/date, not whatever OCR would infer from the (past)
+printed date. `RealTestHelpers.futureExpiry` and `addCouponViaCaptionAsync`'s default
+`expiryDate` already do this for you.
+
+As of merge #269, `/add`'s OCR-extracted barcode is subject to the partial unique index
+`coupon_barcode_active_uniq (barcode_text, expires_at) WHERE status IN ('available',
+'taken','reported')`. Reusing a fixture whose barcode already has a live row (from an
+earlier test, or from a prior attempt of the SAME test that TestRetry retried) makes the
+`/add` fail with "Купон с таким штрихкодом уже есть в базе…" — an assertion-class
+failure, not retried. **Always add coupons through `RealTestHelpers.addCouponViaCaptionAsync`**,
+which deletes any pre-existing row for that fixture's barcode FIRST — this is what makes
+retry safe and fixture reuse safe across many more tests than there are distinct
+fixtures. Do not hand-roll `SendPhoto` + `/add` caption without also deleting by barcode
+first, or you will reintroduce the flake this task exists to close.
+
+## Helper inventory
+
+### DB (`DbSeed.fs`) — all take `connectionString: string` as the first arg
+
+| Signature | What it does |
+|---|---|
+| `deleteCouponsByBarcodeAsync (connectionString) (barcodeText: string) : Task` | Deletes every `coupon` row for that barcode (all expiries/statuses); `coupon_event` cascades automatically (`ON DELETE CASCADE`, the only FK onto `coupon`). Call BEFORE sending a fixture photo. |
+| `deletePendingAddFlowAsync (connectionString) (userId: int64) : Task` | Clears the interactive `/add` wizard's `pending_add` row for a user (F# type `PendingAddFlow` in `DbService.fs` — the table itself is `pending_add`, not `PendingAddFlow`). |
+| `deletePendingBatchesAsync (connectionString) (userId: int64) : Task` | Clears `pending_add_batch` rows (all statuses) for a user; `pending_add_batch_item` cascades automatically. |
+| `getCouponStatusAsync (connectionString) (couponId: int) : Task` | `{ status; owner_id; taken_by: Nullable }` for one coupon id. |
+| `getLatestOwnedCouponIdAsync (connectionString) (ownerId: int64) : Task` | Newest coupon id for an owner (`ORDER BY id DESC LIMIT 1`) — the pattern several existing tests inline. |
+| `setCouponExpiryAsync (connectionString) (couponId: int) (expiresAt: DateOnly) : Task` | Overwrites `coupon.expires_at` for one coupon id via direct SQL. |
+| `truncateCouponsAsync` / `truncateBatchesAsync` / `getSettingAsync` / `setSettingAsync` / `applyAsync` | Pre-existing — unchanged. |
+
+### Telegram (`TgUserClient.fs`) — new members alongside the existing `SendText` / `SendPhoto` / `AwaitTextContaining` / `FindCallbackData` / `PressCallbackButton` etc.
+
+| Signature | What it does |
+|---|---|
+| `PressCallbackButtonMatching(chatId, msg, predicate, description) : Task` | `FindCallbackData` + `PressCallbackButton` in one call. On a miss, fails with the searched-for `description` AND the full list of callback data actually present on `msg` — not an `AwaitTimeoutException` (the message already arrived; a missing button is a real behavioral assertion). |
+| `AwaitAndPressButton(chatId, afterMsgId, textMarker, timeout, buttonPredicate, buttonDescription) : Task` | `AwaitTextContaining` + `PressCallbackButtonMatching` in one call; returns the awaited message. The await half is retryable, the press half is not — see above. |
+
+### Cross-cutting (`RealTestHelpers.fs`)
+
+| Signature | What it does |
+|---|---|
+| `fixtureBarcodes: Map` | Fixture filename -> its ground-truth barcode, read off the filename itself (`[value]_[minCheck]_[validFrom]_[validTo]_[barcode].jpg`, per `CouponHubBot.Ocr.Tests/OcrTests.fs`'s own parsing/assertion). Two "low quality" entries are barcode-unproven — see the doc comment in the file. |
+| `futureExpiry (daysFromNow: float) : string` | `yyyy-MM-dd` date that many days from now (UTC). |
+| `addCouponViaCaptionAsync (fx) (fixtureImage: string) (value: string) (minCheck: string) (expiryDate: string option) : Task` | **The add helper.** Deletes any existing coupon for the fixture's barcode, sends the photo with an explicit `/add` caption, awaits the confirmation, parses and returns the new coupon id. Pass `None` for the default (365 days out). Use this for every new `/add`. |
+
+## Adding a new file
+
+Stub files already exist and are already registered in the `.fsproj`, in F#-compile-order
+position, after `RealTestHelpers.fs`/`RealAssemblyFixture.fs` and before `Program.fs`:
+
+- `AddWizardRealTests.fs`
+- `ReportButtonFlowRealTests.fs`
+- `MyAndAddedRealTests.fs`
+- `AdminAndFeedbackRealTests.fs`
+
+**Do not touch the `.fsproj`, `DbSeed.fs`, `TgUserClient.fs`, `RealTestHelpers.fs`, or any
+other author's stub file.** Replace only your own file's placeholder `[]` with real
+`[]` methods. If you need a helper that doesn't exist yet, add it as a `private`
+function inside your own file rather than editing shared plumbing — if it turns out to be
+genuinely shared, flag it back to whoever owns this scaffolding instead of editing shared
+files yourself.
+
+## Isolation model (no automatic per-test cleanup)
+
+There is still no assembly-wide per-test truncation. Tests isolate by:
+(a) querying their own newest coupon/state via helpers above, scoped by owner id;
+(b) fixture reuse now being safe (see above) instead of needing a distinct fixture per
+test;
+(c) `afterMsgId` gating in every `Await*` call — always pass the message id returned by
+the `Send*` call that triggered the expected reply, never `0`, or an earlier test's
+identical reply text can satisfy your `Await*` immediately.
diff --git a/tests/CouponHubBot.RealTests/RealTestHelpers.fs b/tests/CouponHubBot.RealTests/RealTestHelpers.fs
new file mode 100644
index 0000000..c925574
--- /dev/null
+++ b/tests/CouponHubBot.RealTests/RealTestHelpers.fs
@@ -0,0 +1,122 @@
+namespace CouponHubBot.RealTests
+
+open System
+open System.Globalization
+open System.IO
+open System.Text.RegularExpressions
+open System.Threading.Tasks
+
+/// Cross-cutting real-test helpers that need BOTH DbSeed (DB) and TgUserClient/
+/// RealAssemblyFixture (Telegram) — the centrepiece being `addCouponViaCaptionAsync`,
+/// which the "shared plumbing" task exists to hand the four expansion-suite authors so
+/// none of them has to re-derive it. Kept out of TgUserClient.fs (a deliberate
+/// near-verbatim COPY of AlitaBot.RealTests/TgUserClient.fs — see its own doc comment on
+/// why edits there are minimized to Telegram-only, fixture-agnostic plumbing) and out of
+/// DbSeed.fs (admin-connection DB-only helpers). See README.md for the full inventory.
+module RealTestHelpers =
+
+    /// Ground-truth barcode for each fixture in tests/CouponHubBot.Ocr.Tests/Images/,
+    /// read off the filename's own encoding: OcrTests.fs's
+    /// `Parsing.parseExpectedFromFileName` documents the convention as
+    /// "[couponValue]_[minCheck]_[validFrom]_[validTo]_[barcode].jpg" (OcrTests.fs:166-178),
+    /// and its `` `OCR engine recognizes coupon from file` `` theory (OcrTests.fs:216-232)
+    /// asserts `CouponOcrEngine.Recognize` reproduces that exact 5th field as `res.barcode`
+    /// for every filename below EXCEPT the last two — these are NOT invented values, they
+    /// are read directly off each fixture's own name.
+    ///
+    /// The last two entries ("low quality") come from OcrTests.fs's separate
+    /// `` `OCR engine recognizes coupon from low quality file` `` theory
+    /// (OcrTests.fs:275-286), which deliberately passes `null` for `expectedBarcode` on
+    /// both — i.e. reliable barcode extraction from these two specific photos is NOT
+    /// proven the way it is for the other sixteen. They're listed here anyway (same
+    /// filename-encoded value, for completeness/consistency), but a caller reaching for
+    /// a "definitely idempotent" fixture should prefer one of the other sixteen; if OCR
+    /// mis-extracts (or fails to extract) a barcode from either of these two on a given
+    /// run, `deleteCouponsByBarcodeAsync` — keyed on the filename-encoded value below —
+    /// would not find and clear that run's coupon row.
+    let fixtureBarcodes: Map =
+        Map.ofList [
+            "10_50_01-04_01-13_2706602781191.jpg", "2706602781191"
+            "10_50_01-06_01-15_2706643333717.jpg", "2706643333717"
+            "10_50_01-12_01-21_2706513420233.jpg", "2706513420233"
+            "10_50_01-12_01-21_2706530490622.jpg", "2706530490622"
+            "10_50_01-14_01-23_2706658654210.jpg", "2706658654210"
+            "10_50_01-19_01-28_2706613152454.jpg", "2706613152454"
+            "10_50_01-21_01-30_2706616470579.jpg", "2706616470579"
+            "5_25_01-15_01-21_2706528422291.jpg", "2706528422291"
+            "5_25_01-15_01-21_2706666377231.jpg", "2706666377231"
+            "5_25_01-15_01-21_2706684806638.jpg", "2706684806638"
+            "5_25_2026-02-08_2026-02-14_2706726228947.jpg", "2706726228947"
+            "5_25_2026-04-05_2026-04-15_2706873139950.jpg", "2706873139950"
+            "10_50_2026-01-11_2026-01-20_2706678568818.jpg", "2706678568818"
+            "10_50_2026-01-17_2026-01-26_2706688198838.jpg", "2706688198838"
+            "10_50_2026-01-17_2026-01-26_2706688198845.jpg", "2706688198845"
+            "10_50_2026-01-17_2026-01-26_2706688198821.jpg", "2706688198821"
+            // Low-quality pair — barcode NOT proven reliable by OcrTests.fs, see doc comment above.
+            "5_25_2026-01-11_2026-01-17_2706653336241.jpg", "2706653336241"
+            "5_25_2026-01-20_2026-01-26_2706680353051.jpg", "2706680353051"
+        ]
+
+    /// A `yyyy-MM-dd` date `daysFromNow` days out. DbService.TryAddCoupon rejects
+    /// `expires_at < todayUtc()` (DbService.fs:198-200) and every fixture image is
+    /// deliberately dated in the past (public-repo constraint — see AddFlowRealTests.fs's
+    /// doc comment), so every /add through a fixture needs its expiry overridden via the
+    /// caption's explicit date, same trick every existing add-flow real test already uses.
+    let futureExpiry (daysFromNow: float) : string =
+        DateTime.UtcNow.AddDays(daysFromNow).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+
+    /// Matches the coupon id out of the bot's "Добавлен купон ID:: ..." confirmation
+    /// (the exact template — CallbackHandler.fs:284,329 and CouponFlowHandler.fs:166).
+    let private couponIdInConfirmation = Regex(@"ID:(\d+)", RegexOptions.Compiled)
+
+    /// THE add helper (contract, Deliverable 1 "An add helper — the centrepiece"). Sends
+    /// `fixtureImage` (a key of `fixtureBarcodes`) as a photo with an explicit
+    /// "/add   " caption, awaits the "Добавлен купон ID:: ..."
+    /// confirmation, and returns  parsed straight out of that reply text (no DB round
+    /// trip needed — DbSeed.getLatestOwnedCouponIdAsync remains available separately for
+    /// tests that add via other means, e.g. ReminderRealTests.fs's direct-SQL insert).
+    ///
+    /// Retry-safe / fixture-reusable (the whole point of this task — contract's "problem"
+    /// section): deletes any pre-existing coupon row for this fixture's barcode FIRST via
+    /// DbSeed.deleteCouponsByBarcodeAsync. Without this: (a) TestRetry's one automatic
+    /// retry after an AwaitTimeoutException would re-send the identical fixture photo
+    /// into coupon_barcode_active_uniq's partial unique index and turn a flaky timeout
+    /// into a hard, NON-retried assertion failure ("Купон с таким штрихкодом уже есть в
+    /// базе…"); (b) the expansion suite reuses these same 16-18 fixtures across many more
+    /// tests than there are distinct barcodes for.
+    ///
+    /// `expiryDate` defaults to 365 days out (`futureExpiry 365.`) when `None` — pass
+    /// `Some ` only when the test itself is exercising expiry-dependent
+    /// behavior. (`?`-optional-argument syntax is member-only in F# — FS0718 — hence the
+    /// explicit `option` here rather than the brief's suggested bare positional arg.)
+    let addCouponViaCaptionAsync
+        (fx: RealAssemblyFixture)
+        (fixtureImage: string)
+        (value: string)
+        (minCheck: string)
+        (expiryDate: string option)
+        : Task =
+        task {
+            let barcode =
+                match fixtureBarcodes.TryFind fixtureImage with
+                | Some b -> b
+                | None ->
+                    failwith
+                        $"'{fixtureImage}' has no known barcode in RealTestHelpers.fixtureBarcodes — add it there (read the barcode off the fixture's OWN filename, see OcrTests.fs's naming convention; do not invent one) before calling addCouponViaCaptionAsync with it"
+
+            do! DbSeed.deleteCouponsByBarcodeAsync fx.DbConnectionString barcode
+
+            let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, fixtureImage)
+            if not (File.Exists imagePath) then
+                failwith $"Expired-coupon fixture missing: {imagePath}"
+
+            let expiry = expiryDate |> Option.defaultWith (fun () -> futureExpiry 365.)
+            let! sentId = fx.UserClient.SendPhoto(fx.BotChatId, imagePath, $"/add {value} {minCheck} {expiry}")
+            let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Добавлен купон", TimeSpan.FromSeconds 90.)
+
+            let m = couponIdInConfirmation.Match reply.message
+            if not m.Success then
+                failwith $"Could not parse a coupon id out of the add confirmation text: '{reply.message}'"
+
+            return int m.Groups[1].Value
+        }
diff --git a/tests/CouponHubBot.RealTests/ReminderRealTests.fs b/tests/CouponHubBot.RealTests/ReminderRealTests.fs
index 592cb7d..3b800e3 100644
--- a/tests/CouponHubBot.RealTests/ReminderRealTests.fs
+++ b/tests/CouponHubBot.RealTests/ReminderRealTests.fs
@@ -52,15 +52,33 @@ RETURNING id;
                        expires_at = futureExpiry
                        taken_at = takenAt.ToString("o", CultureInfo.InvariantCulture) |})
 
-            // Fresh baseline in this long-lived private DM's message-id sequence — the
-            // reminder is triggered over plain HTTP (no MTProto send of our own), so
-            // there is no naturally fresh "afterMsgId" otherwise (see TgUserClient.fs's
-            // doc comment on why AfterMsgId-gating exists at all).
-            let! baselineId = fx.UserClient.SendText(fx.BotChatId, "/help")
-            let! _helpReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, baselineId, "Команды", TimeSpan.FromSeconds 30.)
+            // Cleanup is mandatory, not cosmetic: this coupon's `photo_file_id` is a
+            // synthetic GUID string, never a real Telegram file id, and its `barcode_text`
+            // is NULL (deleteCouponsByBarcodeAsync can never find/remove it by barcode).
+            // Left behind 'taken' for the rest of this serial run, it (a) permanently eats
+            // one of this account's MAX_TAKEN_COUPONS=6 slots, and (b) gets included in
+            // every later /my's photo/media-group send (CommandHandler.fs:196-199,
+            // `GetCouponsTakenBy` has no way to exclude it) — Telegram rejects that whole
+            // request with "wrong remote file identifier specified: Wrong padding in the
+            // string", crashing the /my handler for every later test in the run
+            // (confirmed against run 30181924500's bot pod logs, UpdateId 84308640/84308644).
+            // F#'s task CE can't `do!`/`let!` inside a `finally` block, hence try/with +
+            // explicit re-raise (same shape AdminAndFeedbackRealTests.fs's "short aliases"
+            // test already uses for the same reason).
+            try
+                // Fresh baseline in this long-lived private DM's message-id sequence — the
+                // reminder is triggered over plain HTTP (no MTProto send of our own), so
+                // there is no naturally fresh "afterMsgId" otherwise (see TgUserClient.fs's
+                // doc comment on why AfterMsgId-gating exists at all).
+                let! baselineId = fx.UserClient.SendText(fx.BotChatId, "/help")
+                let! _helpReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, baselineId, "Команды", TimeSpan.FromSeconds 30.)
 
-            do! fx.RunReminderAsync(DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture))
+                do! fx.RunReminderAsync(DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture))
 
-            let! dm = fx.UserClient.AwaitTextContaining(fx.BotChatId, baselineId, $"ID:{couponId}", TimeSpan.FromSeconds 60.)
-            Assert.Contains("Напоминание", dm.message)
+                let! dm = fx.UserClient.AwaitTextContaining(fx.BotChatId, baselineId, $"ID:{couponId}", TimeSpan.FromSeconds 60.)
+                Assert.Contains("Напоминание", dm.message)
+                do! DbSeed.deleteCouponByIdAsync fx.DbConnectionString couponId
+            with ex ->
+                do! DbSeed.deleteCouponByIdAsync fx.DbConnectionString couponId
+                raise ex
         })
diff --git a/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs
new file mode 100644
index 0000000..588cb07
--- /dev/null
+++ b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs
@@ -0,0 +1,205 @@
+namespace CouponHubBot.RealTests
+
+open System
+open Xunit
+
+/// Covers the button-driven `/report` flow — `/my` -> `report` -> `report:`
+/// (coupon selection) -> `report::confirm`, `reportCancel` at both the select and
+/// confirm steps, and the adder-side `reportedUsed:` acknowledgement
+/// (CallbackHandler.fs's `data = "report"` / `data.StartsWith "report:"` /
+/// `data = "reportCancel"` / `data.StartsWith "reportedUsed:"` branches).
+/// Complements ReportFlowRealTests.fs, which only exercises the TEXT `/report `
+/// command, not this button chain.
+///
+/// Same single-account caveat as CouponLifecycleRealTests/ReportFlowRealTests: this
+/// suite drives exactly ONE real Telegram account, so the coupon's owner and its
+/// reporter are the same account throughout — that is expected and is exactly what
+/// ReportFlowRealTests' text-`/report` test already does.
+type ReportButtonFlowRealTests(fx: RealAssemblyFixture) =
+
+    /// Adds a coupon (via the shared retry-safe helper) and immediately takes it, so
+    /// every test in this file starts from "one taken coupon, held by this account" —
+    /// the precondition for the `report` bottom-row button to appear on `/my`
+    /// (CommandHandler.fs:257 only renders it when `taken` is non-empty).
+    let addTakenCoupon (fixtureImage: string) (value: string) (minCheck: string) =
+        task {
+            let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx fixtureImage value minCheck None
+            let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
+            let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
+            return couponId
+        }
+
+    /// Sends `/my` and awaits the reply mentioning the given coupon id — the common
+    /// entry point every test below uses to reach the `report` bottom-row button.
+    let openMyMentioning (couponId: int) =
+        task {
+            let! mySentId = fx.UserClient.SendText(fx.BotChatId, "/my")
+            return! fx.UserClient.AwaitTextContaining(fx.BotChatId, mySentId, $"Купон ID:{couponId}", TimeSpan.FromSeconds 60.)
+        }
+
+    /// Full happy-path chain: `/my` -> press `report` -> press `report:` for the
+    /// coupon set up by this test -> press `report::confirm`. Asserts the
+    /// reporter-facing confirmation text (CommandHandler.fs's handleReport, "отправлен
+    /// владельцу") and that the coupon's DB status becomes 'reported'
+    /// (DbService.TryReportCoupon's terminal UPDATE).
+    []
+    member _.``report button chain: my -> report -> report id -> confirm marks the coupon reported``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = addTakenCoupon "10_50_01-19_01-28_2706613152454.jpg" "10" "50"
+            let! myMsg = openMyMentioning couponId
+
+            do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, myMsg, (fun d -> d = "report"), "report (bottom-row entry point)")
+            let! selectMsg =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, "Какой купон уже использован?", TimeSpan.FromSeconds 60.)
+
+            do!
+                fx.UserClient.PressCallbackButtonMatching(
+                    fx.BotChatId,
+                    selectMsg,
+                    (fun d -> d = $"report:{couponId}"),
+                    $"report:{couponId}")
+            let! confirmMsg =
+                fx.UserClient.AwaitTextContaining(
+                    fx.BotChatId,
+                    selectMsg.id,
+                    $"Купон ID:{couponId} уйдёт владельцу",
+                    TimeSpan.FromSeconds 60.)
+            Assert.Contains("Подтвердить?", confirmMsg.message)
+
+            do!
+                fx.UserClient.PressCallbackButtonMatching(
+                    fx.BotChatId,
+                    confirmMsg,
+                    (fun d -> d = $"report:{couponId}:confirm"),
+                    $"report:{couponId}:confirm")
+            let! reporterReply =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "отправлен владельцу", TimeSpan.FromSeconds 60.)
+            Assert.Contains($"ID:{couponId}", reporterReply.message)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("reported", status.status)
+        })
+
+    /// `reportCancel` pressed at the SELECT step (right after `/my` -> `report`, before
+    /// picking a coupon). Asserts the "Ок, отменено." text and that the coupon's status
+    /// is unchanged (still 'taken' — no report was ever filed).
+    []
+    member _.``reportCancel at the select step cancels without changing coupon status``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = addTakenCoupon "10_50_01-21_01-30_2706616470579.jpg" "10" "50"
+            let! myMsg = openMyMentioning couponId
+
+            do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, myMsg, (fun d -> d = "report"), "report (bottom-row entry point)")
+            let! selectMsg =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, "Какой купон уже использован?", TimeSpan.FromSeconds 60.)
+
+            do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, selectMsg, (fun d -> d = "reportCancel"), "reportCancel")
+            let! cancelReply =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, selectMsg.id, "Ок, отменено.", TimeSpan.FromSeconds 60.)
+            Assert.Equal("Ок, отменено.", cancelReply.message)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("taken", status.status)
+
+            // Cleanup, not assertion: the "still taken" assertion above is this test's own
+            // point. Left 'taken' forever, this coupon would permanently eat one of this
+            // account's shared MAX_TAKEN_COUPONS=6 slots for the rest of the serial run —
+            // see DbSeed.releaseTakenCouponAsync's doc comment.
+            do! DbSeed.releaseTakenCouponAsync fx.DbConnectionString couponId
+        })
+
+    /// `reportCancel` pressed at the CONFIRM step (`/my` -> `report` -> `report:` ->
+    /// `reportCancel`, one step further than the test above). Same assertions: "Ок,
+    /// отменено." and an unchanged (still 'taken') coupon status.
+    []
+    member _.``reportCancel at the confirm step cancels without changing coupon status``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = addTakenCoupon "5_25_01-15_01-21_2706528422291.jpg" "5" "25"
+            let! myMsg = openMyMentioning couponId
+
+            do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, myMsg, (fun d -> d = "report"), "report (bottom-row entry point)")
+            let! selectMsg =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, "Какой купон уже использован?", TimeSpan.FromSeconds 60.)
+
+            do!
+                fx.UserClient.PressCallbackButtonMatching(
+                    fx.BotChatId,
+                    selectMsg,
+                    (fun d -> d = $"report:{couponId}"),
+                    $"report:{couponId}")
+            let! confirmMsg =
+                fx.UserClient.AwaitTextContaining(
+                    fx.BotChatId,
+                    selectMsg.id,
+                    $"Купон ID:{couponId} уйдёт владельцу",
+                    TimeSpan.FromSeconds 60.)
+
+            do! fx.UserClient.PressCallbackButtonMatching(fx.BotChatId, confirmMsg, (fun d -> d = "reportCancel"), "reportCancel")
+            let! cancelReply =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Ок, отменено.", TimeSpan.FromSeconds 60.)
+            Assert.Equal("Ок, отменено.", cancelReply.message)
+
+            let! status = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("taken", status.status)
+
+            // Cleanup, not assertion — see the sibling test above's identical comment.
+            do! DbSeed.releaseTakenCouponAsync fx.DbConnectionString couponId
+        })
+
+    /// Owner-side `reportedUsed:` acknowledgement (§4). Setup drives the coupon into
+    /// 'reported' via the TEXT `/report ` command rather than re-walking the button
+    /// chain the first test above already covers — a more robust, shorter precondition
+    /// for a test whose actual subject is the `/my` `reportedUsed:` button, not the
+    /// report chain itself.
+    ///
+    /// Also asserts what CommandHandler.fs's handleMy actually renders for a 'reported'
+    /// coupon: it appears under the "⚠️ Отмечены как использованные вне бота" heading
+    /// (CommandHandler.fs:238), and — unlike a 'taken' coupon's row — offers no
+    /// "Вернуть"/`return:` button, only `reportedUsed:` (CommandHandler.fs:249-255:
+    /// recirculating a known-dead coupon is the exact harm the report feature exists to
+    /// stop).
+    []
+    member _.``reportedUsed button marks a reported coupon used from the owner's /my``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            let! couponId = addTakenCoupon "5_25_01-15_01-21_2706666377231.jpg" "5" "25"
+
+            let! reportSentId = fx.UserClient.SendText(fx.BotChatId, $"/report {couponId}")
+            let! _reportReply =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, reportSentId, "отправлен владельцу", TimeSpan.FromSeconds 60.)
+
+            let! reportedStatus = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("reported", reportedStatus.status)
+
+            let! mySentId = fx.UserClient.SendText(fx.BotChatId, "/my")
+            let! myMsg =
+                fx.UserClient.AwaitTextContaining(
+                    fx.BotChatId,
+                    mySentId,
+                    "Отмечены как использованные вне бота",
+                    TimeSpan.FromSeconds 60.)
+            Assert.Contains($"ID:{couponId}", myMsg.message)
+
+            let hasReturn = fx.UserClient.FindCallbackData(myMsg, fun d -> d = $"return:{couponId}")
+            Assert.True(hasReturn.IsNone, $"Did not expect a return:{couponId} button on a reported coupon's /my row")
+
+            do!
+                fx.UserClient.PressCallbackButtonMatching(
+                    fx.BotChatId,
+                    myMsg,
+                    (fun d -> d = $"reportedUsed:{couponId}"),
+                    $"reportedUsed:{couponId}")
+            let! usedReply =
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, myMsg.id, $"Купон ID:{couponId} отмечен", TimeSpan.FromSeconds 60.)
+            Assert.Contains("отмечен как использованный", usedReply.message)
+
+            let! finalStatus = DbSeed.getCouponStatusAsync fx.DbConnectionString couponId
+            Assert.Equal("used", finalStatus.status)
+        })
diff --git a/tests/CouponHubBot.RealTests/ReportFlowRealTests.fs b/tests/CouponHubBot.RealTests/ReportFlowRealTests.fs
index 70d60a0..47e6996 100644
--- a/tests/CouponHubBot.RealTests/ReportFlowRealTests.fs
+++ b/tests/CouponHubBot.RealTests/ReportFlowRealTests.fs
@@ -1,8 +1,6 @@
 namespace CouponHubBot.RealTests
 
 open System
-open System.Globalization
-open System.IO
 open Dapper
 open Npgsql
 open Xunit
@@ -17,26 +15,40 @@ open Xunit
 /// land in the same private chat — this test asserts on BOTH distinct texts rather
 /// than on which chat each landed in (which is how the hermetic ReportFlowTests.fs
 /// tells them apart, using two different synthetic user ids).
+///
+/// Root-caused (run 30181924500, failure 1): this test used to hand-roll
+/// SendPhoto + "/add" directly, with no delete-by-barcode-first step. Its final status
+/// is 'reported' — an ACTIVE status under both `coupon_barcode_active_uniq`'s partial
+/// index AND (this is the actual trigger) DbService.TryAddCoupon's primary duplicate-
+/// barcode check (DbService.fs:231-238), which — unlike its own race-condition recovery
+/// path a few lines below (DbService.fs:296-305) — does NOT filter by status at all, so
+/// it also treats a `voided`/`used`/`returned` row for the same barcode+future-expiry as
+/// a blocking duplicate. In run 30181924500, MyAndAddedRealTests' "added lists..." test
+/// ran first, added+voided a coupon for this SAME fixture's barcode (2706658654210,
+/// expiry 365 days out), and that VOIDED row then wrongly blocked this test's own
+/// (non-deleting) /add on both the original attempt and TestRetry's one retry — see
+/// `RealTestHelpers.addCouponViaCaptionAsync`'s doc comment for the general shape of this
+/// hazard. Switched to that idempotent helper (deletes any existing row for the fixture's
+/// barcode FIRST, regardless of status) so this test no longer depends on what any other
+/// test using the same fixture left behind. The underlying DbService.fs:231-238 status-
+/// blind duplicate check is a genuine production bug — reported separately, not fixed
+/// here (out of scope: src/ changes need a human decision per this task's brief).
 type ReportFlowRealTests(fx: RealAssemblyFixture) =
 
-    let futureExpiry = DateTime.UtcNow.AddDays(200.).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
-
     []
     member _.``report command removes the coupon from the pool and DMs the adder``() =
         TestRetry.withTimeoutRetry (fun () -> task {
             fx.SkipUnlessUserClient()
 
-            let imagePath = Path.Combine(RealEnv.ocrFixtureImagesDir, "10_50_01-14_01-23_2706658654210.jpg")
-            Assert.True(File.Exists imagePath, $"Expired-coupon fixture missing: {imagePath}")
-
-            let! addSentId = fx.UserClient.SendPhoto(fx.BotChatId, imagePath, $"/add 10 50 {futureExpiry}")
-            let! _addReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, addSentId, "Добавлен купон", TimeSpan.FromSeconds 90.)
+            let! couponId =
+                RealTestHelpers.addCouponViaCaptionAsync
+                    fx
+                    "10_50_01-14_01-23_2706658654210.jpg"
+                    "10"
+                    "50"
+                    (Some(RealTestHelpers.futureExpiry 200.))
 
             use conn = new NpgsqlConnection(fx.DbConnectionString)
-            let! couponId =
-                conn.QuerySingleAsync(
-                    "SELECT id FROM coupon WHERE owner_id=@o ORDER BY id DESC LIMIT 1",
-                    {| o = fx.UserClient.Me.id |})
 
             let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
             let! _taken = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)
diff --git a/tests/CouponHubBot.RealTests/TgUserClient.fs b/tests/CouponHubBot.RealTests/TgUserClient.fs
index 6c79420..a3ee061 100644
--- a/tests/CouponHubBot.RealTests/TgUserClient.fs
+++ b/tests/CouponHubBot.RealTests/TgUserClient.fs
@@ -285,6 +285,60 @@ type TgUserClient(apiId: string, apiHash: string, sessionPath: string, phone: st
         }
         :> Task
 
+    /// FindCallbackData + PressCallbackButton in one call — nearly every wizard/callback
+    /// step in the real suite is exactly that pair (see CouponLifecycleRealTests.fs and
+    /// BulkAddRealTests.fs, which both do it by hand today). Unlike a bare
+    /// `FindCallbackData` miss (currently surfaced per call site as
+    /// `Assert.True(data.IsSome, "Expected a foo:X button")`, which says nothing about
+    /// what buttons WERE on the message), a miss here raises with the full list of
+    /// callback_data actually present, so a wrong-button failure is self-explanatory
+    /// without a debugger. Deliberately NOT an AwaitTimeoutException: `msg` already
+    /// arrived, so a missing button is a real behavioral assertion about the bot's
+    /// reply, not flakiness — TestRetry.withTimeoutRetry does not catch this (see its
+    /// own doc comment on why assertion failures must not be retried).
+    member this.PressCallbackButtonMatching(chatId: int64, msg: TL.Message, predicate: string -> bool, description: string) : Task =
+        task {
+            match this.FindCallbackData(msg, predicate) with
+            | Some data -> do! this.PressCallbackButton(chatId, msg.id, data)
+            | None ->
+                let present =
+                    match msg.reply_markup with
+                    | :? ReplyInlineMarkup as markup ->
+                        markup.rows
+                        |> Array.collect (fun r -> r.buttons)
+                        |> Array.choose (fun b ->
+                            match b with
+                            | :? KeyboardButtonCallback as cb -> Some(Encoding.UTF8.GetString cb.data)
+                            | _ -> None)
+                        |> String.concat ", "
+                    | _ -> ""
+
+                failwith
+                    $"No button matching '{description}' on message {msg.id} in chat {chatId}. Callback data present: [{present}]"
+        }
+        :> Task
+
+    /// AwaitTextContaining + PressCallbackButtonMatching in one call — the shape of
+    /// nearly every step in the expansion suite's wizard/callback chains ("wait for the
+    /// next prompt, tap the button that advances it"). The await half can raise
+    /// AwaitTimeoutException (retryable, per TestRetry); the press half raises via
+    /// PressCallbackButtonMatching above (not retryable) — callers get both failure
+    /// modes for free without re-deriving which is which at each call site.
+    member this.AwaitAndPressButton
+        (
+            chatId: int64,
+            afterMsgId: int,
+            textMarker: string,
+            timeout: TimeSpan,
+            buttonPredicate: string -> bool,
+            buttonDescription: string
+        ) : Task =
+        task {
+            let! msg = this.AwaitTextContaining(chatId, afterMsgId, textMarker, timeout)
+            do! this.PressCallbackButtonMatching(chatId, msg, buttonPredicate, buttonDescription)
+            return msg
+        }
+
     /// Human-readable dialog list with Bot API chat id conventions (-100… for channels)
     /// — `make coupon-tg-chats` uses this to find the CI group id.
     member _.ListDialogsAsync() =