Files
tiennm99bot/internal/modules/weather/flood_test.go
T
tiennm99 053fd07e5a feat(weather)!: add /thuyvan flood alerts for Tân Thuận
/thuyvan shows the 5-day tide-peak forecast at Phú An and Nhà Bè from
the Đài KTTV Nam Bộ bulletin PDF, the Open-Meteo rain forecast, and live
VNDMS river gauges within 30 km. /thuyvan_subscribe opts a chat into a
10:30 ICT push, retried at 12:30, sent only when a forecast peak reaches
báo động I (1.40 m) or a day's rain reaches 50 mm. A missing or stale
bulletin fails the push instead of reading as no risk.

The thoitiet module is renamed to weather; the /thoitiet* commands keep
their names.

BREAKING CHANGE: the module key is now "weather". A MODULES list that
names thoitiet fails at startup and must name weather instead.
2026-10-02 13:06:07 +07:00

353 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weather
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/util/subscription"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// floodNow is 2026-10-02 12:00 ICT, the day of the bulletin fixture.
func floodNow() time.Time { return time.Date(2026, 10, 2, 5, 0, 0, 0, time.UTC) }
// fakeFloodUpstreams serves KTTV Nam Bộ, VNDMS and Open-Meteo from one
// httptest server. A non-zero status fails that upstream.
type fakeFloodUpstreams struct {
forecastBody string
bulletin string // issue date YYYYMMDD of the served PDF; default 20261002
kttvnbStatus int
vndmsStatus int
forecastStatus int
vndmsReferers []string
mu sync.Mutex
}
func stubFloodUpstreams(t *testing.T, f *fakeFloodUpstreams) {
t.Helper()
if f.bulletin == "" {
f.bulletin = "20261002"
}
pdfData, err := os.ReadFile("testdata/HCMC_TVHN_" + f.bulletin + ".pdf")
if err != nil {
t.Fatal(err)
}
lv0, _ := os.ReadFile("testdata/vndms_lv0.json")
lv2, _ := os.ReadFile("testdata/vndms_lv2.json")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fail := func(status int) bool {
if status != 0 {
http.Error(w, "unavailable", status)
return true
}
return false
}
switch {
case r.URL.Path == "/kttvnb/":
if !fail(f.kttvnbStatus) {
d := f.bulletin
_, _ = w.Write([]byte(`<a href="/kttvnb/index.php/thuy-van/29194-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-` +
d[6:8] + "-" + d[4:6] + "-" + d[0:4] + `">`))
}
case strings.HasPrefix(r.URL.Path, "/kttvnb/index.php/"):
_, _ = w.Write([]byte(`<a class="at_url" href="/kttvnb/attachments/article/29194/HCMC_TVHN_` + f.bulletin + `.pdf">`))
case strings.HasSuffix(r.URL.Path, ".pdf"):
_, _ = w.Write(pdfData)
case r.URL.Path == "/vndms/water_level":
f.mu.Lock()
f.vndmsReferers = append(f.vndmsReferers, r.Header.Get("Referer"))
f.mu.Unlock()
if fail(f.vndmsStatus) {
return
}
switch r.URL.Query().Get("lv") {
case "0":
_, _ = w.Write(lv0)
case "2":
_, _ = w.Write(lv2)
default:
_, _ = w.Write([]byte(`{"type":"FeatureCollection","features":[]}`))
}
case r.URL.Path == "/forecast":
if !fail(f.forecastStatus) {
_, _ = w.Write([]byte(f.forecastBody))
}
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
origK, origV, origF := kttvnbURL, vndmsURL, forecastURL
kttvnbURL, vndmsURL, forecastURL = server.URL+"/kttvnb", server.URL+"/vndms", server.URL+"/forecast"
t.Cleanup(func() { kttvnbURL, vndmsURL, forecastURL = origK, origV, origF })
}
func newTestFlood() *flood {
col := storage.NewMemoryProvider().Collection(CollectionName)
return &flood{
client: http.DefaultClient,
subscribers: storage.Typed[subscription.Doc](col),
pushDate: storage.Typed[subscription.DayDoc](col),
nowFn: floodNow,
}
}
func installFlood(t *testing.T, f *flood) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg := &modules.Registry{
Modules: []modules.Module{{Name: CollectionName, Commands: f.commands()}},
AllCommands: map[string]modules.Command{},
}
for _, c := range f.commands() {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
func TestThuyvan_RendersAllSources(t *testing.T) {
up := &fakeFloodUpstreams{forecastBody: forecastFixture}
stubFloodUpstreams(t, up)
rb := installFlood(t, newTestFlood())
want := "🌊 Thuỷ văn — Tân Thuận (Q.7)\n" +
"Đỉnh triều dự báo, Phú An / Nhà Bè (bản tin 02/10):\n" +
"02/10: triều 1,33 / 1,34 m (06:00), mưa 7,9 mm\n" +
"03/10: triều 1,18 / 1,19 m (07:00), mưa 7,0 mm\n" +
"04/10: triều 1,01 / 1,03 m (21:30), mưa 5,7 mm\n" +
"05/10: triều 0,84 / 0,88 m (22:30), mưa 13,9 mm\n" +
"06/10: triều 1,13 / 0,70 m (01:30), mưa 4,0 mm\n" +
"Mực nước hiện tại:\n" +
"Phú An (Sài Gòn, 2,9 km): 1,33 m lúc 7h 02/10\n" +
"Nhà Bè (Đồng Điền, 6,8 km): 1,18 m lúc 7h 02/10\n" +
"Biên Hòa (Đồng Nai, 25,0 km): 1,74 m lúc 7h 02/10\n" +
"⚠️ Thủ Dầu Một (Sài Gòn, 26,1 km): 1,56 m lúc 7h 02/10 — BĐ II\n" +
"Báo động I/II/III tại Phú An, Nhà Bè: 1,40 / 1,50 / 1,60 m\n" +
floodSourceLine
if got := send(rb, "/thuyvan"); got != want {
t.Errorf("reply:\n%s\nwant:\n%s", got, want)
}
for _, ref := range up.vndmsReferers {
if ref != vndmsURL+"/" {
t.Errorf("VNDMS Referer = %q, want %q", ref, vndmsURL+"/")
}
}
}
func TestThuyvan_PartialFailureKeepsOtherSources(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, kttvnbStatus: http.StatusBadGateway, vndmsStatus: http.StatusForbidden})
rb := installFlood(t, newTestFlood())
got := send(rb, "/thuyvan")
for _, part := range []string{"Chưa lấy được bản tin dự báo triều.", "02/10: mưa 7,9 mm", "Chưa lấy được mực nước hiện tại."} {
if !strings.Contains(got, part) {
t.Errorf("reply missing %q:\n%s", part, got)
}
}
if strings.Contains(got, "triều 1,") {
t.Errorf("reply shows tide numbers without a bulletin:\n%s", got)
}
}
func TestThuyvan_AllSourcesDown(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{kttvnbStatus: 500, vndmsStatus: 500, forecastStatus: 500})
rb := installFlood(t, newTestFlood())
if got := send(rb, "/thuyvan"); got != floodFetchErrorText {
t.Errorf("reply = %q, want %q", got, floodFetchErrorText)
}
}
func TestThuyvanSubscribe_Idempotent(t *testing.T) {
f := newTestFlood()
rb := installFlood(t, f)
if got := send(rb, "/thuyvan_subscribe"); !strings.Contains(got, "Đã đăng ký cảnh báo ngập") {
t.Errorf("first subscribe = %q", got)
}
if got := send(rb, "/thuyvan_subscribe"); got != "Chat này đã đăng ký rồi." {
t.Errorf("second subscribe = %q", got)
}
subs, _ := subscription.List(context.Background(), f.subscribers)
if len(subs) != 1 || subs[0].ChatID != 7 {
t.Errorf("subscribers = %+v, want chat 7", subs)
}
if got := send(rb, "/thuyvan_unsubscribe"); got != "Đã huỷ cảnh báo ngập cho chat này." {
t.Errorf("unsubscribe = %q", got)
}
if got := send(rb, "/thuyvan_unsubscribe"); got != "Chat này chưa đăng ký." {
t.Errorf("second unsubscribe = %q", got)
}
}
type recordingSender struct {
calls []bot.SendMessageParams
err error
}
func (s *recordingSender) SendMessage(_ context.Context, p *bot.SendMessageParams) (*models.Message, error) {
s.calls = append(s.calls, *p)
return &models.Message{}, s.err
}
func TestRunFloodPush_SkipsWithoutRisk(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture})
f := newTestFlood()
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
sender := &recordingSender{}
if err := runFloodPush(context.Background(), f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 0 {
t.Errorf("pushed %d messages on a day without risk", len(sender.calls))
}
if _, _, err := f.pushDate.Get(context.Background(), floodPushDateKey); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("a no-risk day must not claim the push date: err=%v", err)
}
}
func TestRunFloodPush_HeavyRainAlertsOncePerDay(t *testing.T) {
// 62 mm forecast for 03/10.
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: strings.Replace(forecastFixture,
`"precipitation_sum":[5.30,7.90,7.00`, `"precipitation_sum":[5.30,7.90,62.00`, 1)})
f := newTestFlood()
ctx := context.Background()
_, _ = subscription.Add(ctx, f.subscribers, 7, 0)
_, _ = subscription.Add(ctx, f.subscribers, 8, 3)
sender := &recordingSender{}
if err := runFloodPush(ctx, f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 2 {
t.Fatalf("pushed %d messages, want 2", len(sender.calls))
}
want := "⚠️ Cảnh báo ngập — Tân Thuận (Q.7)\n" +
"⚠️ 03/10: triều 1,18 / 1,19 m (07:00), mưa 62,0 mm — mưa lớn\n" +
"Xem chi tiết: /thuyvan\n" + floodSourceLine
if got := sender.calls[0].Text; got != want {
t.Errorf("push:\n%s\nwant:\n%s", got, want)
}
if sender.calls[1].ChatID != int64(8) || sender.calls[1].MessageThreadID != 3 {
t.Errorf("second push target = %v/%d, want 8/3", sender.calls[1].ChatID, sender.calls[1].MessageThreadID)
}
if err := runFloodPush(ctx, f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 2 {
t.Errorf("second run the same day pushed again: %d messages", len(sender.calls))
}
}
func TestRunFloodPush_PrunesBlockedChats(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: strings.Replace(forecastFixture,
`"precipitation_sum":[5.30,7.90`, `"precipitation_sum":[5.30,80.00`, 1)})
f := newTestFlood()
ctx := context.Background()
_, _ = subscription.Add(ctx, f.subscribers, 7, 0)
sender := &recordingSender{err: errors.New("Forbidden: bot was blocked by the user")}
if err := runFloodPush(ctx, f, sender); err != nil {
t.Fatal(err)
}
if subs, _ := subscription.List(ctx, f.subscribers); len(subs) != 0 {
t.Errorf("blocked chat not pruned: %+v", subs)
}
}
func TestRunFloodPush_FailsWhenNoForecast(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{kttvnbStatus: 500, forecastStatus: 500})
f := newTestFlood()
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
if err := runFloodPush(context.Background(), f, &recordingSender{}); err == nil {
t.Error("want an error when neither tide nor rain forecast is available")
}
}
func TestThuyvan_RainFailureIsNoted(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastStatus: http.StatusBadGateway})
rb := installFlood(t, newTestFlood())
got := send(rb, "/thuyvan")
for _, part := range []string{"Chưa lấy được dự báo mưa.", "02/10: triều 1,33 / 1,34 m (06:00)\n"} {
if !strings.Contains(got, part) {
t.Errorf("reply missing %q:\n%s", part, got)
}
}
}
func TestThuyvan_StaleBulletinIsNotUsed(t *testing.T) {
// The newest linked bulletin is from 10/09, three weeks before today.
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, bulletin: "20260910"})
rb := installFlood(t, newTestFlood())
got := send(rb, "/thuyvan")
if !strings.Contains(got, "Chưa lấy được bản tin dự báo triều.") || strings.Contains(got, "bản tin 10/09") {
t.Errorf("stale bulletin used:\n%s", got)
}
}
func TestRunFloodPush_TideAlert(t *testing.T) {
// The 10/09 bulletin forecasts báo động I–II at Phú An and Nhà Bè from
// 11/09; the rain fixture's dates do not overlap, so tide alone triggers.
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, bulletin: "20260910"})
f := newTestFlood()
f.nowFn = func() time.Time { return time.Date(2026, 9, 10, 4, 0, 0, 0, time.UTC) }
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
sender := &recordingSender{}
if err := runFloodPush(context.Background(), f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 1 {
t.Fatalf("pushed %d messages, want 1", len(sender.calls))
}
got := sender.calls[0].Text
for _, part := range []string{"⚠️ 11/09: triều 1,37 / 1,41 m (03:30) — BĐ I", "⚠️ 12/09: triều 1,45 / 1,46 m (16:00) — BĐ I"} {
if !strings.Contains(got, part) {
t.Errorf("push missing %q:\n%s", part, got)
}
}
if strings.Contains(got, "10/09") {
t.Errorf("a day below báo động I is in the push:\n%s", got)
}
}
func TestRunFloodPush_FailsWithoutBulletinWhenRainIsQuiet(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, kttvnbStatus: 500})
f := newTestFlood()
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
sender := &recordingSender{}
if err := runFloodPush(context.Background(), f, sender); err == nil {
t.Error("want an error so the missing tide forecast is not read as no risk")
}
if len(sender.calls) != 0 {
t.Errorf("pushed %d messages", len(sender.calls))
}
}
func TestFormatFloodDay_TideTiers(t *testing.T) {
cases := []struct {
height float64
want string
}{
{1.39, "29/09: triều 1,39 m (17:00)"},
{1.40, "⚠️ 29/09: triều 1,40 m (17:00) — BĐ I"},
{1.55, "⚠️ 29/09: triều 1,55 m (17:00) — BĐ II"},
{1.60, "⚠️ 29/09: triều 1,60 m (17:00) — BĐ III"},
}
for _, c := range cases {
d := floodDay{Date: time.Date(2026, 9, 29, 0, 0, 0, 0, ictZone), Peaks: []tidePeak{{c.height, "17:00"}},
HasTide: true, Tier: tideTier(c.height)}
if got := formatFloodDay(d); got != c.want {
t.Errorf("height %.2f: %q, want %q", c.height, got, c.want)
}
}
}