Files
tiennm99bot/internal/modules/weather/weather_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

307 lines
11 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"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// forecastFixture mirrors a live Open-Meteo response for Ho Chi Minh City.
const forecastFixture = `{
"current":{"time":"2026-10-01T10:30","temperature_2m":29.0,"apparent_temperature":35.7,"relative_humidity_2m":78,"weather_code":3,"wind_speed_10m":3.2},
"hourly":{
"time":["2026-10-01T10:00","2026-10-01T11:00","2026-10-01T12:00","2026-10-01T13:00","2026-10-01T14:00","2026-10-01T15:00","2026-10-01T16:00"],
"temperature_2m":[30.4,31.4,32.1,32.1,32.2,32.7,31.9],
"apparent_temperature":[38.1,40.2,41.3,40.9,40.2,38.2,36.6],
"weather_code":[3,3,3,3,51,1,2],
"precipitation_probability":[2,2,4,10,23,41,55],
"precipitation":[0.00,0.00,0.00,0.00,0.10,0.00,0.00],
"relative_humidity_2m":[73,71,68,68,64,57,60],
"wind_speed_10m":[3.0,2.4,1.6,1.1,1.4,2.8,5.7]
},
"daily":{
"time":["2026-10-01","2026-10-02","2026-10-03","2026-10-04","2026-10-05","2026-10-06","2026-10-07"],
"weather_code":[80,80,81,95,95,95,53],
"temperature_2m_max":[32.7,31.8,32.0,32.2,32.5,31.1,31.1],
"temperature_2m_min":[24.3,23.9,24.2,23.7,24.5,24.0,23.9],
"precipitation_sum":[5.30,7.90,7.00,5.70,13.90,4.00,7.60],
"precipitation_probability_max":[70,88,85,92,98,100,89],
"uv_index_max":[8.90,8.20,8.60,8.40,8.85,7.80,8.70],
"sunrise":["2026-10-01T05:42","2026-10-02T05:42","2026-10-03T05:42","2026-10-04T05:42","2026-10-05T05:42","2026-10-06T05:42","2026-10-07T05:42"],
"sunset":["2026-10-01T17:44","2026-10-02T17:43","2026-10-03T17:42","2026-10-04T17:42","2026-10-05T17:41","2026-10-06T17:40","2026-10-07T17:40"]
}
}`
const daLatGeocodeFixture = `{"results":[
{"name":"Ðà Lạt","latitude":11.94646,"longitude":108.44193,"country_code":"VN","country":"Việt Nam","admin1":"Lam Dong"}
]}`
// fakeOpenMeteo serves both Open-Meteo endpoints for the duration of t and
// records the geocoding queries it received.
type fakeOpenMeteo struct {
geocodeBody string
forecastBody string
forecastStatus int
geocodeQueries []string
forecastCalls atomic.Int32
lastForecastQ map[string]string
}
func stubOpenMeteo(t *testing.T, f *fakeOpenMeteo) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
f.geocodeQueries = append(f.geocodeQueries, r.URL.Query().Get("name"))
_, _ = w.Write([]byte(f.geocodeBody))
case "/forecast":
f.forecastCalls.Add(1)
f.lastForecastQ = map[string]string{}
for k := range r.URL.Query() {
f.lastForecastQ[k] = r.URL.Query().Get(k)
}
if f.forecastStatus != 0 {
http.Error(w, "unavailable", f.forecastStatus)
return
}
_, _ = w.Write([]byte(f.forecastBody))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
origGeocode, origForecast := geocodeURL, forecastURL
geocodeURL, forecastURL = server.URL+"/search", server.URL+"/forecast"
t.Cleanup(func() { geocodeURL, forecastURL = origGeocode, origForecast })
}
func installWeather(t *testing.T) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(CollectionName)})
reg := &modules.Registry{
Modules: []modules.Module{{Name: CollectionName, Commands: mod.Commands}},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
func send(rb *testutil.RecordingBot, text string) string {
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
return rb.LastSent().Text()
}
func TestCommands_RegistrationAndParameters(t *testing.T) {
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(CollectionName)})
want := []struct{ name, parameters string }{
{"thoitiethomnay", "[location...]"},
{"thoitiet", "[location...]"},
{"thoitietngaymai", "[location...]"},
{"thoitiettuannay", "[location...]"},
{"thuyvan", ""},
{"thuyvan_subscribe", ""},
{"thuyvan_unsubscribe", ""},
}
if len(mod.Commands) != len(want) {
t.Fatalf("commands = %d, want %d", len(mod.Commands), len(want))
}
for i, c := range mod.Commands {
if c.Name != want[i].name {
t.Errorf("commands[%d] = %q, want %q", i, c.Name, want[i].name)
}
if c.Parameters != want[i].parameters {
t.Errorf("/%s parameters = %q, want %q", c.Name, c.Parameters, want[i].parameters)
}
if c.Visibility != modules.VisibilityPublic {
t.Errorf("/%s is not public", c.Name)
}
}
if len(mod.Crons) != 2 || mod.Crons[0].Schedule != "30 3 * * *" || mod.Crons[1].Schedule != "30 5 * * *" {
t.Errorf("crons = %+v, want the 10:30 ICT flood push and its 12:30 retry", mod.Crons)
}
}
func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installWeather(t)
want := "🌦️ Thời tiết hôm nay 01/10 — Thành phố Hồ Chí Minh\n" +
"Hiện tại: 29°C (cảm giác 36°C), Nhiều mây ☁️\n" +
"Độ ẩm 78%, gió 3,2 km/h\n" +
"Cả ngày: 24–33°C, Mưa rào nhẹ 🌦️\n" +
"Khả năng mưa 70% (5,3 mm), UV 8,9\n" +
"Mặt trời mọc 05:42, lặn 17:44\n" +
"Nguồn: Open-Meteo"
for _, cmd := range []string{"/thoitiethomnay", "/thoitiethomnay hcm", "/thoitiethomnay Sài Gòn"} {
if got := send(rb, cmd); got != want {
t.Errorf("%s reply =\n%s\nwant\n%s", cmd, got, want)
}
}
if len(f.geocodeQueries) != 0 {
t.Errorf("geocode called with %v, want no calls", f.geocodeQueries)
}
if f.lastForecastQ["latitude"] != "10.82302" || f.lastForecastQ["timezone"] != "auto" ||
f.lastForecastQ["forecast_days"] != "7" || f.lastForecastQ["forecast_hours"] != "7" {
t.Errorf("forecast query = %v", f.lastForecastQ)
}
}
func TestHourly_ListsNextSixHours(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installWeather(t)
want := "🕐 Thời tiết 6 giờ tới — Thành phố Hồ Chí Minh\n" +
"Hiện tại 10:30: 29°C (cảm giác 36°C), Nhiều mây ☁️\n" +
"\n11:00 ☁️ Nhiều mây, 31°C (cảm giác 40°C)\n" +
"Mưa 2% (0,0 mm), độ ẩm 71%, gió 2,4 km/h\n" +
"\n12:00 ☁️ Nhiều mây, 32°C (cảm giác 41°C)\n" +
"Mưa 4% (0,0 mm), độ ẩm 68%, gió 1,6 km/h\n" +
"\n13:00 ☁️ Nhiều mây, 32°C (cảm giác 41°C)\n" +
"Mưa 10% (0,0 mm), độ ẩm 68%, gió 1,1 km/h\n" +
"\n14:00 🌦️ Mưa phùn nhẹ, 32°C (cảm giác 40°C)\n" +
"Mưa 23% (0,1 mm), độ ẩm 64%, gió 1,4 km/h\n" +
"\n15:00 🌤️ Ít mây, 33°C (cảm giác 38°C)\n" +
"Mưa 41% (0,0 mm), độ ẩm 57%, gió 2,8 km/h\n" +
"\n16:00 ⛅ Mây rải rác, 32°C (cảm giác 37°C)\n" +
"Mưa 55% (0,0 mm), độ ẩm 60%, gió 5,7 km/h\n" +
"\nNguồn: Open-Meteo"
if got := send(rb, "/thoitiet"); got != want {
t.Errorf("reply =\n%s\nwant\n%s", got, want)
}
}
func TestHourly_NoUpcomingHoursRepliesError(t *testing.T) {
stale := strings.Replace(forecastFixture, `"time":"2026-10-01T10:30"`, `"time":"2026-10-01T16:30"`, 1)
stubOpenMeteo(t, &fakeOpenMeteo{forecastBody: stale})
rb := installWeather(t)
if got := send(rb, "/thoitiet"); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)
}
}
func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) {
f := &fakeOpenMeteo{geocodeBody: daLatGeocodeFixture, forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installWeather(t)
got := send(rb, "/thoitietngaymai Đà Lạt ")
want := "🌦️ Thời tiết ngày mai T6 02/10 — Ðà Lạt, Lam Dong\n" +
"Dự báo: 24–32°C, Mưa rào nhẹ 🌦️\n" +
"Khả năng mưa 88% (7,9 mm), UV 8,2\n" +
"Mặt trời mọc 05:42, lặn 17:43\n" +
"Nguồn: Open-Meteo"
if got != want {
t.Errorf("reply =\n%s\nwant\n%s", got, want)
}
if len(f.geocodeQueries) != 1 || f.geocodeQueries[0] != "da lat" {
t.Errorf("geocode queries = %q, want [\"da lat\"]", f.geocodeQueries)
}
if f.lastForecastQ["latitude"] != "11.94646" {
t.Errorf("forecast latitude = %q", f.lastForecastQ["latitude"])
}
}
func TestWeek_ListsSevenDays(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installWeather(t)
want := "📅 Thời tiết 7 ngày tới — Thành phố Hồ Chí Minh\n" +
"T5 01/10: 24–33°C 🌦️ Mưa rào nhẹ, mưa 70%\n" +
"T6 02/10: 24–32°C 🌦️ Mưa rào nhẹ, mưa 88%\n" +
"T7 03/10: 24–32°C 🌦️ Mưa rào, mưa 85%\n" +
"CN 04/10: 24–32°C ⛈️ Dông, mưa 92%\n" +
"T2 05/10: 25–33°C ⛈️ Dông, mưa 98%\n" +
"T3 06/10: 24–31°C ⛈️ Dông, mưa 100%\n" +
"T4 07/10: 24–31°C 🌦️ Mưa phùn, mưa 89%\n" +
"Nguồn: Open-Meteo"
if got := send(rb, "/thoitiettuannay"); got != want {
t.Errorf("reply =\n%s\nwant\n%s", got, want)
}
}
func TestAliasExpansionAndForeignPlace(t *testing.T) {
f := &fakeOpenMeteo{
geocodeBody: `{"results":[
{"name":"Tokyo","latitude":35.6895,"longitude":139.69171,"country_code":"JP","country":"Nhật Bản","admin1":"Tokyo"}
]}`,
forecastBody: forecastFixture,
}
stubOpenMeteo(t, f)
rb := installWeather(t)
got := send(rb, "/thoitiettuannay Tokyo")
if !strings.HasPrefix(got, "📅 Thời tiết 7 ngày tới — Tokyo, Nhật Bản\n") {
t.Errorf("reply header = %q", strings.SplitN(got, "\n", 2)[0])
}
send(rb, "/thoitiet HN")
if want := []string{"tokyo", "Ha Noi"}; strings.Join(f.geocodeQueries, "|") != strings.Join(want, "|") {
t.Errorf("geocode queries = %q, want %q", f.geocodeQueries, want)
}
}
func TestUnknownLocation(t *testing.T) {
f := &fakeOpenMeteo{geocodeBody: `{"generationtime_ms":0.3}`}
stubOpenMeteo(t, f)
rb := installWeather(t)
if got, want := send(rb, "/thoitiet xyzzy"), `Không tìm thấy địa điểm "xyzzy".`; got != want {
t.Errorf("reply = %q, want %q", got, want)
}
if n := f.forecastCalls.Load(); n != 0 {
t.Errorf("forecast calls = %d, want 0", n)
}
}
func TestUpstreamFailureRepliesError(t *testing.T) {
cases := map[string]*fakeOpenMeteo{
"forecast non-200": {forecastStatus: http.StatusServiceUnavailable},
"forecast bad json": {forecastBody: "<html>"},
"no daily rows": {forecastBody: `{"daily":{"time":[]}}`},
"geocode bad json": {geocodeBody: "<html>"},
}
for name, f := range cases {
t.Run(name, func(t *testing.T) {
stubOpenMeteo(t, f)
rb := installWeather(t)
cmd := "/thoitiet"
if f.geocodeBody != "" {
cmd = "/thoitiet Hue"
}
if got := send(rb, cmd); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)
}
})
}
}
func TestTomorrow_NeedsTwoDailyRows(t *testing.T) {
oneDay := strings.NewReplacer(
`,"2026-10-02","2026-10-03","2026-10-04","2026-10-05","2026-10-06","2026-10-07"`, "",
).Replace(forecastFixture)
f := &fakeOpenMeteo{forecastBody: oneDay}
stubOpenMeteo(t, f)
rb := installWeather(t)
if got := send(rb, "/thoitietngaymai"); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)
}
if got := send(rb, "/thoitiettuannay"); !strings.Contains(got, "Thời tiết 1 ngày tới") {
t.Errorf("week reply = %q, want a one-day list", got)
}
}