Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/coupon-real-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion tests/CouponHubBot.RealTests/AddFlowRealTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
512 changes: 512 additions & 0 deletions tests/CouponHubBot.RealTests/AddWizardRealTests.fs

Large diffs are not rendered by default.

253 changes: 253 additions & 0 deletions tests/CouponHubBot.RealTests/AdminAndFeedbackRealTests.fs

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions tests/CouponHubBot.RealTests/BulkAddRealTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions tests/CouponHubBot.RealTests/CouponHubBot.RealTests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<Compile Include="TestRetry.fs" />
<Compile Include="DbSeed.fs" />
<Compile Include="RealAssemblyFixture.fs" />
<!-- Cross-cutting DB+Telegram combinators (addCouponViaCaptionAsync/fixtureBarcodes)
— needs RealAssemblyFixture's type, so it must land after it and before every
test file that opens CouponHubBot.RealTests and calls into it. -->
<Compile Include="RealTestHelpers.fs" />
<!-- Runs safely anywhere in the default suite: it flips COMMUNITY_CHAT_ID and
restores it via POST /test/membership/invalidate (Program.fs), which busts
TelegramMembershipService's 1-day cache on both the flip and the restore — see
Expand All @@ -26,6 +30,14 @@
<Compile Include="CouponLifecycleRealTests.fs" />
<Compile Include="ReportFlowRealTests.fs" />
<Compile Include="ReminderRealTests.fs" />
<!-- Expansion-suite stubs (real-tests-expansion scaffolding task) — one file per
parallel author, no shared file among them; only this .fsproj entry list and
RealTestHelpers.fs/DbSeed.fs/TgUserClient.fs above are shared, and per that
task's brief the four authors do not touch any of those. -->
<Compile Include="AddWizardRealTests.fs" />
<Compile Include="ReportButtonFlowRealTests.fs" />
<Compile Include="MyAndAddedRealTests.fs" />
<Compile Include="AdminAndFeedbackRealTests.fs" />
<!-- CompileAfter guarantees the [<EntryPoint>] file lands after build-injected
sources (xunit runner reporters, MTP SelfRegisteredExtensions). -->
<CompileAfter Include="Program.fs" />
Expand Down
44 changes: 14 additions & 30 deletions tests/CouponHubBot.RealTests/CouponLifecycleRealTests.fs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<int>("SELECT id FROM coupon WHERE owner_id=@o ORDER BY id DESC LIMIT 1", {| o = ownerId |})
}

/// Adds a coupon via photo + explicit "/add <value> <minCheck> <futureDate>"
/// 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
}

[<Fact>]
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.)
Expand All @@ -66,14 +40,21 @@ 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
})

[<Fact>]
member _.``my shows the taken coupon with return/used buttons and the adder handle``() =
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.)

Expand All @@ -98,14 +79,17 @@ 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
})

[<Fact>]
member _.``used callback marks the coupon used and stats reflects it``() =
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.)

Expand Down Expand Up @@ -134,7 +118,7 @@ type CouponLifecycleRealTests(fx: RealAssemblyFixture) =
TestRetry.withTimeoutRetry (fun () -> task {
fx.SkipUnlessUserClient()

let! couponId = addCoupon "10_50_01-12_01-21_2706530490622.jpg" "10" "50"
let! couponId = RealTestHelpers.addCouponViaCaptionAsync fx "10_50_01-12_01-21_2706530490622.jpg" "10" "50" None
let! takeSentId = fx.UserClient.SendText(fx.BotChatId, $"/take {couponId}")
let! takenMsg = fx.UserClient.AwaitPhotoCaptionContaining(fx.BotChatId, takeSentId, "теперь твой", TimeSpan.FromSeconds 60.)

Expand Down
Loading
Loading