Skip to content

Real-Telegram test suite green (11/11) + manual /add barcode OCR#269

Merged
Szer merged 7 commits into
mainfrom
feat/manual-add-barcode-ocr
Jul 25, 2026
Merged

Real-Telegram test suite green (11/11) + manual /add barcode OCR#269
Szer merged 7 commits into
mainfrom
feat/manual-add-barcode-ocr

Conversation

@Szer

@Szer Szer commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Gets the real-Telegram (MTProto) test suite from red to 11/11 green, and closes a
duplicate-detection gap in manual /add along the way.

Real-run evidence: https://github.com/Szer/bots/actions/runs/30179358723Passed: 11.
Hermetic suite: 192/192 (188 baseline + 4 new).

Production change — user-visible

Manual /add (photo + caption) now extracts the coupon barcode via OCR instead of storing
null (CouponFlowHandler.fs:106-132, :160-161).

Why it matters: coupon_barcode_active_uniq is a partial unique index on
(barcode_text, expires_at), so a coupon with a null barcode was exempt from duplicate
detection
. Every manual-add coupon bypassed dedupe entirely. Production is 1459/1489 (98%)
barcoded because real users mostly use the wizard/album flows, which already ran OCR.

Consequence to expect: adding a duplicate coupon by caption will now be rejected where it
previously silently succeeded. The user-facing message for this already existed
(AddCouponResult.DuplicateBarcode → «Купон с таким штрихкодом уже есть в базе…») but was
unreachable dead code; it is now live.

Safety properties, each with a test:

  • Gated on OCR_ENABLED, like the other two OCR paths.
  • OCR failure can never block an add. Azure down, image unreadable, file too large → coupon is
    still created with a null barcode. Adding a coupon must not depend on a third-party vision
    service.
  • Caption values always win; OCR supplies only the barcode.

Four real bugs the pipeline found

None were catchable by the hermetic suite, which passed throughout.

  1. COUPON_CI_BOT_TOKEN was set but empty (len=0) — surfaced as a 404 from
    api.telegram.org, which reads as "webhook missing" rather than "token rejected".
  2. CI group had auto-upgraded basic→supergroup, changing its Bot API id. The stale id made
    getChatMember throw chat not found, which MembershipService collapses into false — so the
    bot told a genuine member «Бот доступен только членам сообщества».
  3. PressCallbackButton never sent its callback data. Constructing
    TL.Methods.Messages_GetBotCallbackAnswer via F# object-initializer syntax sets data but not
    the request's flags bitfield, and WriteTL only serializes data when has_data (0x1) is
    set — so every press went out with a valid msg_id and zero bytes, and Telegram answered
    DATA_INVALID. Fixed by using the SchemaExtensions helper, which computes flags.
    This code path had never been executed against real Telegram by anything in this repo.
  4. Album fixtures OCR to their own printed (past) expiry, and TryAddCoupon rejects
    expires_at < today. Manual /add escapes this via the caption's explicit date; the album path
    reads no caption, so all three items were silently skipped and the bulk confirm added zero
    coupons.

Diagnostics added, because the first failures were unreadable

  • Fail-fast validation before the ~5-minute deploy: token shape + getMe, then
    getChat/getChatMemberCount. The first bad-token run burned five minutes to produce a
    misleading error; it now fails in ~12 seconds naming the cause and the fix.
  • Pod logs dumped on failure before teardown. Teardown is if: always(), so it had already
    deleted the pod whose logs the error message told you to read.
  • Error messages that distinguish causes: 404/ok=false points at the token; 200 with a
    wrong url points at WEBHOOK_URL and the pod logs.
  • Also fixed: the CI bot_setting seed used ON CONFLICT DO NOTHING, so migration-seeded values
    silently won and CI's intended BATCH_DEBOUNCE_MS=1000 was discarded in favour of 5000
    (INSERT 0 7 for 8 rows). Now DO UPDATE.
  • Also fixed: getChat's returned id equals the COUPON_CI_CHAT_ID secret, so echoing it leaked
    a secret into the run log. Removed from the echo.

Known limits

  • The suite is not deterministic. The same sha produced 10/11 then 11/11, with /my timing out
    at 90s. TestRetry's single timeout-class retry is not always enough. Expect occasional red from
    real-Telegram latency rather than from regressions.
  • BulkAddRealTests overrides expires_at via direct SQL after OCR lands the items, because
    fixtures must stay expired (this repo is public — a current coupon image would publish a live
    usable barcode) and the album path has no caption to carry a future date. That test therefore does
    not exercise the batch path's expiry validation.
  • /report is covered only as a text command. The «⚠️ Купон уже использован» →
    pick-list → confirm button flow — the path real users take — has no real-Telegram coverage yet.
  • 4 tests drive real inline-button presses; the rest use text commands.

How to run

gh workflow run coupon-real-test.yml --ref <branch>

workflow_dispatch only, never on the PR gate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

Szer and others added 7 commits July 25, 2026 15:54
…od logs on failure

The "Verify webhook self-registration" step's http=404 was misdiagnosed as
"webhook not registered" — a 404/ok=false from Telegram actually means the
TOKEN itself was rejected (an unregistered webhook on a valid token returns
http=200, ok=true, url=""). Adds an early getMe-based token validation step
(shape/whitespace diagnostics, never prints the value) so a bad secret fails
in seconds instead of after the ~5min deploy, splits the misleading error
message into the two distinct failure shapes, and dumps kubectl pod/logs on
failure before the always()-teardown step deletes the evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
Add a getChat/getChatMemberCount validation step for the real-test CI
group, positioned right after the existing bot-token check. A basic
group's Telegram id changes shape when Telegram auto-upgrades it to a
supergroup, and MembershipService.IsMember collapses that failure mode
into the same "not a member" false as a genuine non-member — this
catches a stale id in seconds instead of every real test timing out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
…NCE_MS override

Run 30162785282 (2026-07-25) proved the new "Validate CI chat and bot
membership" step's root-cause diagnosis correct: 10 of 11 real-Telegram
tests failed because TelegramMembershipService.IsMember got "chat not
found" from getChatMember for the CI account against COMMUNITY_CHAT_ID on
every single update (bot log: "Failed to check membership ... chat not
found"), so every command was silently refused. Two follow-up fixes from
reviewing that step and that run's logs:

- The step's echo included getChat's .result.id, which for a real
  group/supergroup is the same value as the COUPON_CI_CHAT_ID secret (or
  its supergroup-migrated form) — printing it leaked the secret into the
  run log. Removed; ok/type/title carry the same diagnostic value.
- "Seed bot_setting" used ON CONFLICT (key) DO NOTHING for all 8 rows, but
  migration V16__pending_add_batch.sql already seeds BATCH_DEBOUNCE_MS=5000
  with its own ON CONFLICT DO NOTHING — so on a freshly-migrated DB the
  CI's intended 1000ms override always lost silently, leaving the real
  debounce at 5000ms (the run's INSERT reported "INSERT 0 7" against 8
  VALUES rows, confirming exactly one row was skipped). Changed to DO
  UPDATE SET value = EXCLUDED.value so the CI's values are authoritative
  regardless of what a migration pre-seeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
Run 30163952072 (2026-07-25) showed used/return real tests failing with
"Expected a used:6/return:7 button on the take confirmation" even though
the take succeeded. Root cause: BotHelpers.singleTakenKeyboard (the
take-confirmation card's keyboard, BotHelpers.fs:290-294) emits
"used:<id>:del" / "return:<id>:del" — CallbackHandler.fs's EndsWith(":del")
handling strips that suffix and deletes the confirmation message on
success — but these two tests searched for the bare "used:<id>"/
"return:<id>" (no suffix), an exact-match predicate that can never match
production's real button data. The /my listing's separate keyboard
(CommandHandler.fs:247-248) genuinely has no suffix, so the "my" test
above was already correct and is untouched. Verified against
CallbackHandler.fs's parsing (:del stripped via EndsWith/Substring) that
the corrected value presses a real, valid button — not weakening either
assertion, just correcting the expected string to match unchanged src
behavior. `dotnet build tests/CouponHubBot.RealTests -c Release` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
…using DATA_INVALID

TL.Methods.Messages_GetBotCallbackAnswer is a raw TL request type whose `data`
field is only serialized when the `flags` bitfield has its has_data bit set
(WriteTL: `if ((flags & Flags.has_data) != 0) writer.WriteTLBytes(data)`).
Constructing it via F# named-argument/object-initializer syntax sets the
`data` property but never touches `flags`, which defaults to 0 — every press
went out with msg_id but zero callback bytes attached, which Telegram
rejects with DATA_INVALID on every single press (4/4 reproducible).
Decompiled via ilspycmd to confirm both the raw request's field layout and
the WTelegram-generated `Client.Messages_GetBotCallbackAnswer` extension
method, which correctly computes `flags` from which optional args are
non-null. Switched PressCallbackButton to that extension method.

Verified against real DM history with @coupon_test_bot (no bot deployed, no
workflow dispatched): the broken raw-request shape reproduced DATA_INVALID
locally; the extension-method shape (now in PressCallbackButton) was
accepted by Telegram and instead got BOT_RESPONSE_TIMEOUT, since no bot is
running to answer it — RPC acceptance is the fix's success criterion here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
…oding null

Manual /add (photo + explicit caption) previously passed a literal null
barcode to TryAddCoupon, exempting those coupons from the
coupon_barcode_active_uniq duplicate check. HandleAddManual now runs OCR
on the photo (gated on OCR_ENABLED, reusing the wizard's GetFile/download
approach) and passes only the extracted barcode through — value, min-check
and expiry always come from the caption. OCR failure (backend error,
timeout, missing file path, oversized image) degrades to a null barcode
exactly as before; adding a coupon never fails because OCR is unavailable.
The existing AddCouponResult.DuplicateBarcode handling in HandleAddManual
was already correct and unused (barcode was always null) — no change
needed there, it just becomes reachable now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
…iry items, not barcode dupes

The three album fixtures already have distinct barcodes (2706688198821/838/845,
confirmed both by reading their raw Azure OCR text and by the passing
CouponHubBot.Ocr.Tests theory cases for these exact filenames), so the
duplicate-barcode hypothesis is refuted.

Real cause: unlike the single-photo /add flow, the album/batch flow has no
caption to override OCR's own printed (past) expiry, and DbService.TryAddCoupon
rejects `expires_at < todayUtc()` for every batch item during BulkBatchConfirm
(DbService.fs:198-200) — same gate the manual /add path already works around via
an explicit future-date caption. Since every fixture in Images/ is deliberately
dated in the past (repo-public constraint), all 3 album items were silently
skipped as "Expired", landing 0 net coupons.

Test-only fix: after OCR lands each item as 'ok', bump their expires_at via
direct SQL to a future date before the bulk-confirm button is pressed — the
same "direct SQL, real-test-only" override pattern ReminderRealTests.fs and
AddFlowRealTests' caption already use. Leaves value/min_check/barcode_text (the
actual OCR output under test) untouched; assertion (3 coupons added) unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU
@Szer
Szer merged commit 12bee58 into main Jul 25, 2026
5 of 6 checks passed
@Szer
Szer deleted the feat/manual-add-barcode-ocr branch July 25, 2026 23:39
Szer added a commit that referenced this pull request Jul 26, 2026
…vered (#272)

* 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 [<Fact>] 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* 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:<id> button, the myAdded button (renders the same as /added), the bare
used:<id>/return:<id> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* 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:<id> -> report:<id>:confirm marks a
coupon reported; reportCancel at both the select and confirm steps leaves the
coupon untouched; and the owner-side reportedUsed:<id> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* 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 <id>: add + take a coupon, assert its event-history dump contains
  both event types.
- /undo <id>: 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* Real-tests: add /add wizard + bulk-cancel happy-path coverage

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* Real-tests expansion: fix cross-test state leak, async cleanup, clock-dependent assertion, add ocr:yes coverage, raise CI timeout

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* Fix 5 real-Telegram CI failures from run 30181924500

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

* Fix real-Telegram test suite: make every coupon-add idempotent regardless of order

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P41roCWN5Fec7m69sSaunU

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant