From 58e2e4d5ce52ab52622374977ef2268058200497 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sun, 26 Jul 2026 01:16:12 +0100 Subject: [PATCH 1/8] Real-tests expansion: shared plumbing + 4 stub files for parallel authors Adds DB/Telegram/cross-cutting helpers to CouponHubBot.RealTests so four agents can each author one expansion test file without touching a shared file: - DbSeed.fs: deleteCouponsByBarcodeAsync, deletePendingAddFlowAsync, deletePendingBatchesAsync, getCouponStatusAsync, getLatestOwnedCouponIdAsync, setCouponExpiryAsync. - TgUserClient.fs: PressCallbackButtonMatching (self-explanatory button-not-found errors) and AwaitAndPressButton, kept assertion-class vs timeout-class distinct so TestRetry's single-retry-on-AwaitTimeoutException semantics are preserved. - RealTestHelpers.fs (new): fixtureBarcodes map (read off each OCR fixture's own filename) and addCouponViaCaptionAsync, the retry-safe/fixture-reusable /add helper that closes the coupon_barcode_active_uniq flake introduced by merge #269. - Four compiling stub files (AddWizardRealTests, ReportButtonFlowRealTests, MyAndAddedRealTests, AdminAndFeedbackRealTests) registered in the .fsproj, each with one placeholder /help-round-trip [] for the four authors to replace. - README.md: authoring guide (skeleton, helper inventory, retry contract, fixture reuse rule, CI-dispatch-only rule). No production code changes; existing 11 real tests left untouched (see report for why the DB-helper substitution was judged more than mechanical and skipped). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU --- .../AddWizardRealTests.fs | 29 ++++ .../AdminAndFeedbackRealTests.fs | 27 ++++ .../CouponHubBot.RealTests.fsproj | 12 ++ tests/CouponHubBot.RealTests/DbSeed.fs | 112 +++++++++++++++ .../MyAndAddedRealTests.fs | 28 ++++ tests/CouponHubBot.RealTests/README.md | 135 ++++++++++++++++++ .../CouponHubBot.RealTests/RealTestHelpers.fs | 122 ++++++++++++++++ .../ReportButtonFlowRealTests.fs | 28 ++++ tests/CouponHubBot.RealTests/TgUserClient.fs | 54 +++++++ 9 files changed, 547 insertions(+) create mode 100644 tests/CouponHubBot.RealTests/AddWizardRealTests.fs create mode 100644 tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs create mode 100644 tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs create mode 100644 tests/CouponHubBot.RealTests/README.md create mode 100644 tests/CouponHubBot.RealTests/RealTestHelpers.fs create mode 100644 tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs diff --git a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs new file mode 100644 index 0000000..a19fe58 --- /dev/null +++ b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs @@ -0,0 +1,29 @@ +namespace CouponHubBot.RealTests + +open System +open Xunit + +/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage +/// yet; the placeholder [] below exists only to keep this file compiling and +/// discoverable until an author replaces it. +/// +/// WILL COVER: the interactive `/add` wizard — bare `/add` -> photo -> `addflow:ocr:yes` +/// / `addflow:ocr:no` -> `addflow:disc::` -> `addflow:date:today` / +/// `addflow:date:tomorrow` / custom-date text entry -> `addflow:confirm` / +/// `addflow:cancel` (CallbackHandler.fs:203-346, CouponFlowHandler.fs), plus the +/// separate `addflow:bulk:cancel` album-flow cancel button. See +/// tests/CouponHubBot.RealTests/README.md for the full helper inventory +/// (RealTestHelpers.addCouponViaCaptionAsync/fixtureBarcodes, DbSeed.deletePendingAddFlowAsync +/// / deletePendingBatchesAsync, TgUserClient.AwaitAndPressButton / +/// PressCallbackButtonMatching) — delete this placeholder [] once real coverage lands. +type AddWizardRealTests(fx: RealAssemblyFixture) = + + [] + member _.``placeholder: help round-trip``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") + let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) + Assert.Contains("/add", reply.message) + }) diff --git a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs new file mode 100644 index 0000000..fd9a384 --- /dev/null +++ b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs @@ -0,0 +1,27 @@ +namespace CouponHubBot.RealTests + +open System +open Xunit + +/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage +/// yet; the placeholder [] below exists only to keep this file compiling and +/// discoverable until an author replaces it. +/// +/// WILL COVER: the `/feedback` flow, admin-only `/debug` and `/undo`, and the bot's +/// short command aliases (see CommandHandler.fs's Dispatch for the alias table). The +/// admin commands need FEEDBACK_ADMINS to include the logged-in MTProto test user's own +/// id — already true by construction, since RealAssemblyFixture seeds FEEDBACK_ADMINS +/// with exactly that id at startup (RealAssemblyFixture.fs's InitializeAsync / +/// DbSeed.applyAsync). See tests/CouponHubBot.RealTests/README.md for the full helper +/// inventory — delete this placeholder [] once real coverage lands. +type AdminAndFeedbackRealTests(fx: RealAssemblyFixture) = + + [] + member _.``placeholder: help round-trip``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") + let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) + Assert.Contains("/feedback", reply.message) + }) 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/DbSeed.fs b/tests/CouponHubBot.RealTests/DbSeed.fs index c6b2489..7f83fc2 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,117 @@ 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 |}) + () + } + /// 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..1442e70 --- /dev/null +++ b/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs @@ -0,0 +1,28 @@ +namespace CouponHubBot.RealTests + +open System +open Xunit + +/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage +/// yet; the placeholder [] below exists only to keep this file compiling and +/// discoverable until an author replaces it. +/// +/// WILL COVER: `/added` (list of coupons this account added), its `void:` callback, +/// the `myAdded` callback (CallbackHandler.fs's `data = "myAdded"` branch), the TEXT +/// commands `/void`, `/used`, `/return`, and the bare `used:` / `return:` +/// buttons rendered under `/my` (distinct from the `:del`-suffixed take-confirmation-card +/// buttons CouponLifecycleRealTests.fs already covers — see that file's doc comment on +/// the `singleTakenKeyboard` vs `/my`-listing keyboard distinction). See +/// tests/CouponHubBot.RealTests/README.md for the full helper inventory — delete this +/// placeholder [] once real coverage lands. +type MyAndAddedRealTests(fx: RealAssemblyFixture) = + + [] + member _.``placeholder: help round-trip``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") + let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) + Assert.Contains("/my", reply.message) + }) diff --git a/tests/CouponHubBot.RealTests/README.md b/tests/CouponHubBot.RealTests/README.md new file mode 100644 index 0000000..eb90201 --- /dev/null +++ b/tests/CouponHubBot.RealTests/README.md @@ -0,0 +1,135 @@ +# 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. + +## 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/ReportButtonFlowRealTests.fs b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs new file mode 100644 index 0000000..3d1a4b4 --- /dev/null +++ b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs @@ -0,0 +1,28 @@ +namespace CouponHubBot.RealTests + +open System +open Xunit + +/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage +/// yet; the placeholder [] below exists only to keep this file compiling and +/// discoverable until an author replaces it. +/// +/// WILL COVER: the button-driven `/report` flow — `/my` -> `report` -> `report:` +/// (coupon selection) -> `report::confirm`, `reportCancel`, 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. See +/// tests/CouponHubBot.RealTests/README.md for the full helper inventory — delete this +/// placeholder [] once real coverage lands. +type ReportButtonFlowRealTests(fx: RealAssemblyFixture) = + + [] + member _.``placeholder: help round-trip``() = + TestRetry.withTimeoutRetry (fun () -> task { + fx.SkipUnlessUserClient() + + let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") + let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) + Assert.Contains("/report", reply.message) + }) 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() = From 60ae71233538b70161451b734c63eb9a70309628 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sun, 26 Jul 2026 01:21:40 +0100 Subject: [PATCH 2/8] Real-Telegram tests: /added + void, myAdded, bare used/return, text /used /return /void Adds 7 happy-path tests to MyAndAddedRealTests.fs covering the slice not already exercised by CouponLifecycleRealTests.fs: /added listing + its bare void: button, the myAdded button (renders the same as /added), the bare used:/return: buttons under /my (distinct from the :del-suffixed take-confirmation-card buttons), and the text commands /used, /return, /void. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU --- .../MyAndAddedRealTests.fs | 182 ++++++++++++++++-- 1 file changed, 167 insertions(+), 15 deletions(-) diff --git a/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs b/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs index 1442e70..b25f9a2 100644 --- a/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs +++ b/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs @@ -3,26 +3,178 @@ namespace CouponHubBot.RealTests open System open Xunit -/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage -/// yet; the placeholder [] below exists only to keep this file compiling and -/// discoverable until an author replaces it. +/// 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). /// -/// WILL COVER: `/added` (list of coupons this account added), its `void:` callback, -/// the `myAdded` callback (CallbackHandler.fs's `data = "myAdded"` branch), the TEXT -/// commands `/void`, `/used`, `/return`, and the bare `used:` / `return:` -/// buttons rendered under `/my` (distinct from the `:del`-suffixed take-confirmation-card -/// buttons CouponLifecycleRealTests.fs already covers — see that file's doc comment on -/// the `singleTakenKeyboard` vs `/my`-listing keyboard distinction). See -/// tests/CouponHubBot.RealTests/README.md for the full helper inventory — delete this -/// placeholder [] once real coverage lands. +/// 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) = [] - member _.``placeholder: help round-trip``() = + /// `/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) + }) + + [] + /// 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! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") - let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) - Assert.Contains("/my", reply.message) + 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) }) From 2db906d1e87582a9a07108ae8aff00c32f1dbd3e Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sun, 26 Jul 2026 01:21:46 +0100 Subject: [PATCH 3/8] Real tests: cover the /report button chain and reportedUsed acknowledgement Adds ReportButtonFlowRealTests.fs (real-Telegram happy paths, this agent's exclusive file): /my -> report -> report: -> report::confirm marks a coupon reported; reportCancel at both the select and confirm steps leaves the coupon untouched; and the owner-side reportedUsed: button marks a 'reported' coupon 'used'. Complements ReportFlowRealTests.fs's TEXT /report coverage with the button-driven chain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU --- .../ReportButtonFlowRealTests.fs | 198 ++++++++++++++++-- 1 file changed, 183 insertions(+), 15 deletions(-) diff --git a/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs index 3d1a4b4..2277c41 100644 --- a/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs +++ b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs @@ -3,26 +3,194 @@ namespace CouponHubBot.RealTests open System open Xunit -/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage -/// yet; the placeholder [] below exists only to keep this file compiling and -/// discoverable until an author replaces it. +/// 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. /// -/// WILL COVER: the button-driven `/report` flow — `/my` -> `report` -> `report:` -/// (coupon selection) -> `report::confirm`, `reportCancel`, 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. See -/// tests/CouponHubBot.RealTests/README.md for the full helper inventory — delete this -/// placeholder [] once real coverage lands. +/// 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 _.``placeholder: help round-trip``() = + member _.``report button chain: my -> report -> report id -> confirm marks the coupon reported``() = TestRetry.withTimeoutRetry (fun () -> task { fx.SkipUnlessUserClient() - let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") - let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) - Assert.Contains("/report", reply.message) + 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) + }) + + /// `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) + }) + + /// 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) }) From 1d58c50212a25bdd0eddfc7d92dce26fc47a4249 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sun, 26 Jul 2026 01:26:49 +0100 Subject: [PATCH 4/8] Real-tests: /feedback, admin /debug + /undo, and short-alias coverage Replaces AdminAndFeedbackRealTests.fs's placeholder with four real tests: - /feedback happy path: arm the flag, send a uniquely-marked message, assert the thank-you reply, the admin forward (lands in the same DM since FEEDBACK_ADMINS is seeded with the test account's own id), and the DB row via a private user_feedback query (no DbSeed.fs helper exists for it). - /debug : add + take a coupon, assert its event-history dump contains both event types. - /undo : add + take a coupon, undo, assert the confirmation text and the DB rollback (taken -> available, taken_by cleared) via DbSeed.getCouponStatusAsync. - Short aliases (/l, /coupons, bare /take, /m, /ad, /s, /a, /f) against CommandHandler.fs's Dispatch, with explicit cleanup of the two stateful aliases (/a's add-wizard row, /f's pending-feedback flag) in a finally block so this test can't corrupt a later test class in the same serial run. No GitHub issue creation is exercised or asserted on (GITHUB_TOKEN is deliberately absent from the CI test pod, gating GitHubService.IsConfigured false). No other file touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU --- .../AdminAndFeedbackRealTests.fs | 215 ++++++++++++++++-- 1 file changed, 201 insertions(+), 14 deletions(-) diff --git a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs index fd9a384..9941157 100644 --- a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs +++ b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs @@ -1,27 +1,214 @@ namespace CouponHubBot.RealTests open System +open System.Threading.Tasks +open Dapper +open Npgsql open Xunit -/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage -/// yet; the placeholder [] below exists only to keep this file compiling and -/// discoverable until an author replaces it. +/// Coverage: the `/feedback` flow, admin-only `/debug` and `/undo`, and the bot's short +/// command aliases (CommandHandler.fs's Dispatch, :390-499). /// -/// WILL COVER: the `/feedback` flow, admin-only `/debug` and `/undo`, and the bot's -/// short command aliases (see CommandHandler.fs's Dispatch for the alias table). The -/// admin commands need FEEDBACK_ADMINS to include the logged-in MTProto test user's own -/// id — already true by construction, since RealAssemblyFixture seeds FEEDBACK_ADMINS -/// with exactly that id at startup (RealAssemblyFixture.fs's InitializeAsync / -/// DbSeed.applyAsync). See tests/CouponHubBot.RealTests/README.md for the full helper -/// inventory — delete this placeholder [] once real coverage lands. +/// 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 |}) + } + + /// 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 _.``placeholder: help round-trip``() = + member _.``debug shows the coupon's event history``() = TestRetry.withTimeoutRetry (fun () -> task { fx.SkipUnlessUserClient() - let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help") - let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.) - Assert.Contains("/feedback", reply.message) + 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)
+        })
+
+    /// 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").
+    []
+    member _.``short aliases behave as their canonical commands``() =
+        TestRetry.withTimeoutRetry (fun () -> task {
+            fx.SkipUnlessUserClient()
+
+            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 — the `finally` below disarms it.
+                let! fSentId = fx.UserClient.SendText(fx.BotChatId, "/f")
+                let! fReply = fx.UserClient.AwaitTextContaining(fx.BotChatId, fSentId, "Следующее твоё сообщение", TimeSpan.FromSeconds 30.)
+                Assert.Contains("Следующее твоё сообщение", fReply.message)
+            finally
+                // Runs even if an assertion above threw. Both cleanups are idempotent
+                // no-ops if their respective state was already cleared (deletePendingAddFlowAsync
+                // is a plain DELETE; any command unconditionally clears pending feedback —
+                // BotService.fs:89-91), so calling both unconditionally here is safe. Waits
+                // for the disarming /help's own reply (rather than firing-and-forgetting the
+                // send) so the flag is guaranteed cleared server-side before this test method
+                // returns and a later test class can send its own first message.
+                DbSeed.deletePendingAddFlowAsync fx.DbConnectionString fx.UserClient.Me.id
+                |> fun t -> t.GetAwaiter().GetResult()
+
+                let helpSentId =
+                    fx.UserClient.SendText(fx.BotChatId, "/help")
+                    |> fun t -> t.GetAwaiter().GetResult()
+
+                fx.UserClient.AwaitTextContaining(fx.BotChatId, helpSentId, "Команды", TimeSpan.FromSeconds 30.)
+                |> fun t -> t.GetAwaiter().GetResult()
+                |> ignore
         })

From d1e21ba131dca4f826a5612f4ca084382a4199a3 Mon Sep 17 00:00:00 2001
From: Ayrat Hudaygulov 
Date: Sun, 26 Jul 2026 01:28:31 +0100
Subject: [PATCH 5/8] Real-tests: add /add wizard + bulk-cancel happy-path
 coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

AddWizardRealTests.fs: full wizard happy path (ocr:no -> disc:10:50 ->
date:today -> confirm, DB-verified), date:tomorrow confirm-screen check,
addflow:cancel, the other three discount buttons (cheap: confirm-screen
assert + cancel, no DB writes), and addflow:bulk:cancel for an album.
addflow:ocr:yes is left untested — unreachable as a happy path for these
(deliberately expired) fixtures, since TryAddCoupon rejects any
expires_at < today before ocr:yes's direct-add path can succeed
(DbService.fs:198-200); addflow:ocr:no explicitly clears the OCR-derived
expiry so every other route reaches a genuine, DB-verified happy path.

Co-Authored-By: Claude Opus 5 (1M context) 
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
---
 .../AddWizardRealTests.fs                     | 371 +++++++++++++++++-
 1 file changed, 355 insertions(+), 16 deletions(-)

diff --git a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
index a19fe58..ff90437 100644
--- a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
+++ b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
@@ -1,29 +1,368 @@
 namespace CouponHubBot.RealTests
 
 open System
+open System.Globalization
+open System.IO
+open System.Threading.Tasks
+open Dapper
+open Npgsql
 open Xunit
+open TL
 
-/// STUB — scaffolding only (contract: "Deliverable 2 — stub files"). No real coverage
-/// yet; the placeholder [] below exists only to keep this file compiling and
-/// discoverable until an author replaces it.
+/// 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.
 ///
-/// WILL COVER: the interactive `/add` wizard — bare `/add` -> photo -> `addflow:ocr:yes`
-/// / `addflow:ocr:no` -> `addflow:disc::` -> `addflow:date:today` /
-/// `addflow:date:tomorrow` / custom-date text entry -> `addflow:confirm` /
-/// `addflow:cancel` (CallbackHandler.fs:203-346, CouponFlowHandler.fs), plus the
-/// separate `addflow:bulk:cancel` album-flow cancel button. See
-/// tests/CouponHubBot.RealTests/README.md for the full helper inventory
-/// (RealTestHelpers.addCouponViaCaptionAsync/fixtureBarcodes, DbSeed.deletePendingAddFlowAsync
-/// / deletePendingBatchesAsync, TgUserClient.AwaitAndPressButton /
-/// PressCallbackButtonMatching) — delete this placeholder [] once real coverage lands.
+/// ── `addflow:ocr:yes` is UNREACHABLE as a happy path for these fixtures, left
+/// untested (see individual test docs 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 any of these fixtures 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 no happy path
+/// through `addflow:ocr:yes` can be constructed without either (a) publishing a
+/// currently-usable coupon photo (forbidden) or (b) overwriting `pending_add.expires_at`
+/// via direct SQL before pressing "yes" — which would stop exercising what a real user
+/// pressing "yes" on a real OCR-recognized (expired) photo actually experiences, i.e.
+/// it would silently paper over the rejection this task's brief explicitly says not to
+/// hide. Left untested per that instruction.
 type AddWizardRealTests(fx: RealAssemblyFixture) =
 
+    let ru = CultureInfo("ru-RU")
+
+    /// Mirrors Utils.DateFormatting.formatDateNoYearWithDow (Utils.fs:36-37) — the
+    /// exact rendering `BotHelpers.formatUiDate` uses for every date shown in wizard
+    /// screens, so test-side expectations can be built the same way the bot itself
+    /// builds its reply text.
+    let formatUiDate (d: DateOnly) = d.ToString("d MMMM, dddd", ru)
+
+    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 |})
+        }
+
+    /// 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:tomorrow. Same chain as test 1 up to the
+    /// date choice, but presses "tomorrow" and asserts the confirm screen reflects
+    /// tomorrow's date — does NOT press addflow:confirm (no DB assertion needed per the
+    /// brief; the wizard is left pending and gets swept by the next test's
+    /// deletePendingAddFlowAsync).
     []
-    member _.``placeholder: help round-trip``() =
+    member _.``date:tomorrow reflects tomorrow's date on the confirm screen``() =
         TestRetry.withTimeoutRetry (fun () -> task {
             fx.SkipUnlessUserClient()
 
-            let! sentId = fx.UserClient.SendText(fx.BotChatId, "/help")
-            let! reply = fx.UserClient.AwaitTextContaining(fx.BotChatId, sentId, "Команды", TimeSpan.FromSeconds 60.)
-            Assert.Contains("/add", reply.message)
+            let ownerId = fx.UserClient.Me.id
+            do! DbSeed.deletePendingAddFlowAsync fx.DbConnectionString ownerId
+            do! DbSeed.deletePendingBatchesAsync fx.DbConnectionString ownerId
+
+            let! discountMsg = reachDiscountChoice "10_50_01-21_01-30_2706616470579.jpg"
+            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 = "addflow:date:tomorrow"), "addflow:date:tomorrow")
+            let! confirmMsg = fx.UserClient.AwaitTextContaining(fx.BotChatId, dateMsg.id, "Подтвердить добавление купона", TimeSpan.FromSeconds 60.)
+
+            // The wizard's "tomorrow" is computed from the bot pod's own (frozen at
+            // boot, unless BOT_FIXED_UTC_NOW is set — Program.fs:111-122) TimeProvider,
+            // not this test process's clock; a midnight-UTC-boundary mismatch between
+            // the two is a known, accepted flake risk for this specific assertion (see
+            // final report).
+            let tomorrow = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1.0))
+            Assert.Contains(formatUiDate tomorrow, confirmMsg.message)
+        })
+
+    /// 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 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:")
+
+            let! _cancelled = fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Ок, пакет отменён.", TimeSpan.FromSeconds 30.)
+            do! waitForBatchCleared batchId
+
+            let! countAfter = countCouponsForOwner ownerId
+            Assert.Equal(countBefore, countAfter)
         })

From cd827b8184982089cc25f5ec341a0f18a83c4595 Mon Sep 17 00:00:00 2001
From: Ayrat Hudaygulov 
Date: Sun, 26 Jul 2026 01:51:19 +0100
Subject: [PATCH 6/8] Real-tests expansion: fix cross-test state leak, async
 cleanup, clock-dependent assertion, add ocr:yes coverage, raise CI timeout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Review fixes on top of the four merged real-test branches:
- AddWizardRealTests.fs: date:tomorrow no longer leaves a pending_add row for
  another test to sweep; rewritten to drive both date:today/date:tomorrow to
  addflow:confirm and assert the DB expiries are exactly one day apart,
  independent of the test process's own clock.
- AddWizardRealTests.fs: added the previously-untestable addflow:ocr:yes
  happy path, reachable by bumping the pending_add row's own expires_at via
  direct SQL (documented why this must never be "cleaned up" — fixtures must
  stay expired since the repo is public).
- AdminAndFeedbackRealTests.fs: alias test's pending-feedback cleanup no
  longer blocks on .GetAwaiter().GetResult() from inside a task body;
  replaced with a DB-level DELETE FROM pending_feedback + try/with re-raise
  so the flag is disarmed on every exit path without a blocking wait.
- .github/workflows/coupon-real-test.yml: timeout-minutes 30 -> 50 for the
  ~21 additional real tests.

Co-Authored-By: Claude Opus 5 (1M context) 
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
---
 .github/workflows/coupon-real-test.yml        |   2 +-
 .../AddWizardRealTests.fs                     | 189 ++++++++++++++----
 .../AdminAndFeedbackRealTests.fs              |  70 +++++--
 3 files changed, 203 insertions(+), 58 deletions(-)

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/AddWizardRealTests.fs b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
index ff90437..1e13d3b 100644
--- a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
+++ b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
@@ -50,33 +50,25 @@ type private WizardCouponRow =
 ///    workaround (unlike BulkAddRealTests.fs's `bumpItemsToFutureExpiry`) is needed
 ///    here — `addflow:ocr:no` already does the equivalent for the interactive wizard.
 ///
-/// ── `addflow:ocr:yes` is UNREACHABLE as a happy path for these fixtures, left
-/// untested (see individual test docs below for the full citation) ──
+/// ── `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 any of these fixtures can only ever reach the
+/// 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 no happy path
-/// through `addflow:ocr:yes` can be constructed without either (a) publishing a
-/// currently-usable coupon photo (forbidden) or (b) overwriting `pending_add.expires_at`
-/// via direct SQL before pressing "yes" — which would stop exercising what a real user
-/// pressing "yes" on a real OCR-recognized (expired) photo actually experiences, i.e.
-/// it would silently paper over the rejection this task's brief explicitly says not to
-/// hide. Left untested per that instruction.
+/// 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 ru = CultureInfo("ru-RU")
-
-    /// Mirrors Utils.DateFormatting.formatDateNoYearWithDow (Utils.fs:36-37) — the
-    /// exact rendering `BotHelpers.formatUiDate` uses for every date shown in wizard
-    /// screens, so test-side expectations can be built the same way the bot itself
-    /// builds its reply text.
-    let formatUiDate (d: DateOnly) = d.ToString("d MMMM, dddd", ru)
-
     let pollInterval = TimeSpan.FromMilliseconds 500.
 
     /// Local copy of BulkAddRealTests.fs's `waitFor` (private to that file's type, not
@@ -151,6 +143,24 @@ type AddWizardRealTests(fx: RealAssemblyFixture) =
                     {| 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
@@ -228,34 +238,64 @@ type AddWizardRealTests(fx: RealAssemblyFixture) =
             Assert.Contains($"ID:{coupon.id}", addedMsg.message)
         })
 
-    /// Test 2 (contract item 2): addflow:date:tomorrow. Same chain as test 1 up to the
-    /// date choice, but presses "tomorrow" and asserts the confirm screen reflects
-    /// tomorrow's date — does NOT press addflow:confirm (no DB assertion needed per the
-    /// brief; the wizard is left pending and gets swept by the next test's
-    /// deletePendingAddFlowAsync).
+    /// 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:tomorrow reflects tomorrow's date on the confirm screen``() =
+    member _.``date:today and date:tomorrow set different, one-day-apart expiries``() =
         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! discountMsg = reachDiscountChoice "10_50_01-21_01-30_2706616470579.jpg"
-            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.)
+            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, dateMsg, (fun d -> d = "addflow:date:tomorrow"), "addflow:date:tomorrow")
-            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.)
 
-            // The wizard's "tomorrow" is computed from the bot pod's own (frozen at
-            // boot, unless BOT_FIXED_UTC_NOW is set — Program.fs:111-122) TimeProvider,
-            // not this test process's clock; a midnight-UTC-boundary mismatch between
-            // the two is a known, accepted flake risk for this specific assertion (see
-            // final report).
-            let tomorrow = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1.0))
-            Assert.Contains(formatUiDate tomorrow, confirmMsg.message)
+                    use conn = new NpgsqlConnection(fx.DbConnectionString)
+                    return!
+                        conn.QuerySingleAsync(
+                            "SELECT expires_at FROM coupon WHERE owner_id = @owner_id ORDER BY id DESC LIMIT 1",
+                            {| owner_id = ownerId |})
+                }
+
+            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,
@@ -319,6 +359,79 @@ type AddWizardRealTests(fx: RealAssemblyFixture) =
                 ()
         })
 
+    /// 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
diff --git a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs
index 9941157..4fc4e64 100644
--- a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs
+++ b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs
@@ -41,6 +41,29 @@ type AdminAndFeedbackRealTests(fx: RealAssemblyFixture) =
                     {| 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
@@ -137,11 +160,28 @@ type AdminAndFeedbackRealTests(fx: RealAssemblyFixture) =
     /// 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
@@ -189,26 +229,18 @@ type AdminAndFeedbackRealTests(fx: RealAssemblyFixture) =
                 // /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 — the `finally` below disarms it.
+                // 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)
-            finally
-                // Runs even if an assertion above threw. Both cleanups are idempotent
-                // no-ops if their respective state was already cleared (deletePendingAddFlowAsync
-                // is a plain DELETE; any command unconditionally clears pending feedback —
-                // BotService.fs:89-91), so calling both unconditionally here is safe. Waits
-                // for the disarming /help's own reply (rather than firing-and-forgetting the
-                // send) so the flag is guaranteed cleared server-side before this test method
-                // returns and a later test class can send its own first message.
-                DbSeed.deletePendingAddFlowAsync fx.DbConnectionString fx.UserClient.Me.id
-                |> fun t -> t.GetAwaiter().GetResult()
-
-                let helpSentId =
-                    fx.UserClient.SendText(fx.BotChatId, "/help")
-                    |> fun t -> t.GetAwaiter().GetResult()
-
-                fx.UserClient.AwaitTextContaining(fx.BotChatId, helpSentId, "Команды", TimeSpan.FromSeconds 30.)
-                |> fun t -> t.GetAwaiter().GetResult()
-                |> ignore
+
+                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
         })

From 500f10898b1e20e49b41918c2a269cb7a7b33a66 Mon Sep 17 00:00:00 2001
From: Ayrat Hudaygulov 
Date: Sun, 26 Jul 2026 08:52:38 +0100
Subject: [PATCH 7/8] Fix 5 real-Telegram CI failures from run 30181924500
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Root-caused each from bot pod logs + CI test-runner logs, not guessed:

1. ReportFlowRealTests "report command..." — hand-rolled, non-deleting /add
   collided with a leftover VOIDED row for the same fixture barcode left by
   MyAndAddedRealTests (which ran first), because DbService.TryAddCoupon's
   primary duplicate-barcode check doesn't filter by status (production bug,
   reported separately, not fixed here). Switched to the idempotent
   RealTestHelpers.addCouponViaCaptionAsync helper.

2. AdminAndFeedbackRealTests "short aliases..." — its /m step crashed
   handleMy's sendMediaGroupPhotos on a coupon with a synthetic, non-Telegram
   photo_file_id that ReminderRealTests inserts directly via SQL and never
   cleaned up (unbarcode-tied, so deleteCouponsByBarcodeAsync could never
   find it). Confirmed via the two "wrong remote file identifier ... Wrong
   padding" bot-log errors landing exactly on this test's two TestRetry
   attempts. Fixed by deleting the coupon after ReminderRealTests' assertion.

3. AdminAndFeedbackRealTests "undo rewinds..." — its own /take hit
   DbService.TryTakeCoupon's LimitReached (MAX_TAKEN_COUPONS=6) because
   several earlier tests leave a coupon 'taken' with no further use and never
   release it. Added DbSeed.releaseTakenCouponAsync and called it after each
   such test's own assertions (CouponLifecycleRealTests x2,
   ReportButtonFlowRealTests x2, MyAndAddedRealTests x1), plus the
   ReminderRealTests cleanup from (2), eliminating the permanent leaks.

4. AddWizardRealTests "addflow:bulk:cancel..." — awaited a NEW Telegram
   message for the cancel confirmation, but BulkBatchCancel delivers it via
   EditBulkOrSend, which EDITS the existing message in place; TgUserClient's
   `record` explicitly drops all edits, so the await could never succeed by
   construction. Switched to the file's own waitForBatchCleared DB poll,
   matching BulkAddRealTests' sibling confirm test's existing pattern.

5. AddWizardRealTests "date:today and date:tomorrow..." — QuerySingleAsync
   against a bare DateOnly scalar silently returned default(DateOnly)
   (Dapper doesn't treat DateOnly as a scalar-mappable type). Mapped into
   CouponRow instead, matching the working pattern used elsewhere in the
   same file.

Co-Authored-By: Claude Opus 5 (1M context) 
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
---
 .../AddWizardRealTests.fs                     | 39 +++++++++++++++++--
 .../CouponLifecycleRealTests.fs               | 10 +++++
 tests/CouponHubBot.RealTests/DbSeed.fs        | 39 +++++++++++++++++++
 .../MyAndAddedRealTests.fs                    |  6 +++
 .../ReminderRealTests.fs                      | 36 ++++++++++++-----
 .../ReportButtonFlowRealTests.fs              |  9 +++++
 .../ReportFlowRealTests.fs                    | 38 +++++++++++-------
 7 files changed, 151 insertions(+), 26 deletions(-)

diff --git a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
index 1e13d3b..3a17707 100644
--- a/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
+++ b/tests/CouponHubBot.RealTests/AddWizardRealTests.fs
@@ -284,11 +284,27 @@ type AddWizardRealTests(fx: RealAssemblyFixture) =
                     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)
-                    return!
-                        conn.QuerySingleAsync(
-                            "SELECT expires_at FROM coupon WHERE owner_id = @owner_id ORDER BY id DESC LIMIT 1",
+                    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"
@@ -473,7 +489,22 @@ type AddWizardRealTests(fx: RealAssemblyFixture) =
                     (fun d -> d.StartsWith "addflow:bulk:cancel:"),
                     "addflow:bulk:cancel:")
 
-            let! _cancelled = fx.UserClient.AwaitTextContaining(fx.BotChatId, confirmMsg.id, "Ок, пакет отменён.", TimeSpan.FromSeconds 30.)
+            // 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
diff --git a/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs b/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
index fdce529..f56ba5d 100644
--- a/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
+++ b/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
@@ -66,6 +66,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
         })
 
     []
@@ -98,6 +105,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
         })
 
     []
diff --git a/tests/CouponHubBot.RealTests/DbSeed.fs b/tests/CouponHubBot.RealTests/DbSeed.fs
index 7f83fc2..ae426c4 100644
--- a/tests/CouponHubBot.RealTests/DbSeed.fs
+++ b/tests/CouponHubBot.RealTests/DbSeed.fs
@@ -198,6 +198,45 @@ let deletePendingBatchesAsync (connectionString: string) (userId: int64) : Task
         ()
     }
 
+/// 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
index b25f9a2..6be0a3a 100644
--- a/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs
+++ b/tests/CouponHubBot.RealTests/MyAndAddedRealTests.fs
@@ -70,6 +70,12 @@ type MyAndAddedRealTests(fx: RealAssemblyFixture) =
             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
         })
 
     []
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
index 2277c41..588cb07 100644
--- a/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs
+++ b/tests/CouponHubBot.RealTests/ReportButtonFlowRealTests.fs
@@ -104,6 +104,12 @@ type ReportButtonFlowRealTests(fx: RealAssemblyFixture) =
 
             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:` ->
@@ -141,6 +147,9 @@ type ReportButtonFlowRealTests(fx: RealAssemblyFixture) =
 
             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
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.)

From 1d62872c2a9cef23acd7ae4be3f53235907eea13 Mon Sep 17 00:00:00 2001
From: Ayrat Hudaygulov 
Date: Sun, 26 Jul 2026 09:28:01 +0100
Subject: [PATCH 8/8] Fix real-Telegram test suite: make every coupon-add
 idempotent regardless of order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

CI run 30193635437 (28/32) failed 4 tests via duplicate-barcode rejection because
AddFlowRealTests, CouponLifecycleRealTests's local addCoupon helper, and
BulkAddRealTests's album confirm all sent /add without deleting any pre-existing
row for their fixture's barcode first. The previous pass's "the later user always
deletes first" reasoning is wrong: test class order is not guaranteed, so whichever
add runs second collides. Route CouponLifecycleRealTests through the shared
RealTestHelpers.addCouponViaCaptionAsync helper, delete-by-barcode-first in
AddFlowRealTests (kept inline — it tests the manual-add path itself) and
BulkAddRealTests (all 3 album fixtures). Also close a taken-coupon leak the prior
MAX_TAKEN_COUPONS audit missed: AdminAndFeedbackRealTests's "debug shows..." test
took a coupon and never released it. Document the idempotence rule prominently in
README.md so a future test author can't reintroduce this.

Co-Authored-By: Claude Opus 5 (1M context) 
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
---
 .../AddFlowRealTests.fs                       | 13 ++++++-
 .../AdminAndFeedbackRealTests.fs              |  7 ++++
 .../BulkAddRealTests.fs                       | 15 ++++++--
 .../CouponLifecycleRealTests.fs               | 34 +++---------------
 tests/CouponHubBot.RealTests/README.md        | 35 +++++++++++++++++++
 5 files changed, 71 insertions(+), 33 deletions(-)

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/AdminAndFeedbackRealTests.fs b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs
index 4fc4e64..cef1dbc 100644
--- a/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs
+++ b/tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs
@@ -119,6 +119,13 @@ type AdminAndFeedbackRealTests(fx: RealAssemblyFixture) =
             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
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/CouponLifecycleRealTests.fs b/tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
index f56ba5d..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.)
@@ -80,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.)
 
@@ -115,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.)
 
@@ -144,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/README.md b/tests/CouponHubBot.RealTests/README.md
index eb90201..f4f6047 100644
--- a/tests/CouponHubBot.RealTests/README.md
+++ b/tests/CouponHubBot.RealTests/README.md
@@ -6,6 +6,41 @@ post to real Telegram. Never run it locally or from an agent; the CI workflow th
 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