mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks. - Created `automatic_tasks_test.go` to validate task claiming and deletion behavior. - Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution. - Integrated device and eSIM profile selection for task configuration. - Implemented automatic task scheduling and retry logic in the backend.
435 lines
16 KiB
Go
435 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"vocat/internal/device"
|
|
"vocat/internal/modem"
|
|
"vocat/internal/store"
|
|
"vocat/internal/vowifi"
|
|
)
|
|
|
|
func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
baseURL string
|
|
want string
|
|
}{
|
|
{
|
|
name: "base URL",
|
|
baseURL: "https://api.telegram.org",
|
|
want: "https://api.telegram.org/bot123456:test-token/sendMessage",
|
|
},
|
|
{
|
|
name: "reverse proxy template",
|
|
baseURL: "https://telegram.example.com/bot%s/%s",
|
|
want: "https://telegram.example.com/bot123456:test-token/sendMessage",
|
|
},
|
|
}
|
|
for _, item := range tests {
|
|
t.Run(item.name, func(t *testing.T) {
|
|
got, err := telegramAPIURL(item.baseURL, "123456:test-token", "sendMessage")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.String() != item.want {
|
|
t.Fatalf("telegramAPIURL() = %q, want %q", got, item.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
|
for _, value := range []string{
|
|
"https://telegram.example.com/bot%s/sendMessage",
|
|
"https://%s.example.com/bot/token/%s",
|
|
"http://telegram.example.com/bot%s/%s",
|
|
} {
|
|
if _, err := telegramAPIURL(value, "123456:test-token", "sendMessage"); err == nil {
|
|
t.Errorf("telegramAPIURL(%q) unexpectedly succeeded", value)
|
|
} else if strings.TrimSpace(err.Error()) == "" {
|
|
t.Errorf("telegramAPIURL(%q) returned an empty error", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTelegramCommand(t *testing.T) {
|
|
command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ")
|
|
if command != "sms" || remainder != "EC20 +447700900123 hello world" {
|
|
t.Fatalf("parseTelegramCommand() = %q, %q", command, remainder)
|
|
}
|
|
if command, _ := parseTelegramCommand("ordinary message"); command != "" {
|
|
t.Fatalf("non-command parsed as %q", command)
|
|
}
|
|
}
|
|
|
|
func TestSplitTelegramArgumentsPreservesMessageBody(t *testing.T) {
|
|
parts := splitTelegramArguments(" EC20 +447700900123 code with spaces ", 3)
|
|
if len(parts) != 3 || parts[0] != "EC20" || parts[1] != "+447700900123" || parts[2] != "code with spaces" {
|
|
t.Fatalf("splitTelegramArguments() = %#v", parts)
|
|
}
|
|
}
|
|
|
|
func TestValidTelegramDialNumber(t *testing.T) {
|
|
for _, value := range []string{"10086", "+447700900123", "12345678901234567890"} {
|
|
if !validTelegramDialNumber(value) {
|
|
t.Errorf("validTelegramDialNumber(%q) = false", value)
|
|
}
|
|
}
|
|
for _, value := range []string{"12", "+", "123;ATH", "12 34", "123456789012345678901"} {
|
|
if validTelegramDialNumber(value) {
|
|
t.Errorf("validTelegramDialNumber(%q) = true", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveTelegramPhoneNumberPrefersCurrentSIMAssociation(t *testing.T) {
|
|
snapshot := &device.Snapshot{
|
|
ICCID: "89441000400128013903",
|
|
Phone: device.PhoneNumber{Number: "00000000000"},
|
|
}
|
|
state := &vowifi.State{
|
|
ICCID: snapshot.ICCID,
|
|
PhoneNumber: "+447386125520",
|
|
}
|
|
if got := resolveTelegramPhoneNumber("+447700900123", state, snapshot); got != "+447700900123" {
|
|
t.Fatalf("resolved association number = %q", got)
|
|
}
|
|
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "+447386125520" {
|
|
t.Fatalf("resolved IMS number = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.T) {
|
|
snapshot := &device.Snapshot{
|
|
ICCID: "current-card",
|
|
Phone: device.PhoneNumber{Number: "00000000000"},
|
|
}
|
|
state := &vowifi.State{
|
|
ICCID: "previous-card",
|
|
PhoneNumber: "+447386083638",
|
|
}
|
|
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
|
t.Fatalf("stale or placeholder number leaked as %q", got)
|
|
}
|
|
for _, placeholder := range []string{"00000000000", "1111111111", "+0000000000", "not-a-number"} {
|
|
if usableTelegramPhoneNumber(placeholder) {
|
|
t.Errorf("placeholder %q was accepted", placeholder)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
|
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
|
t.Fatalf("home carrier = %q", got)
|
|
}
|
|
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
|
|
t.Fatalf("branded foreign-core carrier = %q", got)
|
|
}
|
|
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
|
|
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
|
|
t.Fatalf("flight-mode serving network = %q", got)
|
|
}
|
|
serving := &device.Snapshot{OperatorCode: "46001", RegistrationStatus: 5, AccessTech: "LTE", Band: "B3"}
|
|
if got := telegramCurrentNetwork(serving); !strings.Contains(got, "🇨🇳") || !strings.Contains(got, "已驻网(漫游)") {
|
|
t.Fatalf("serving network = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
|
|
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
|
|
action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()}
|
|
token, err := bot.putPending(action)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := bot.takePending(token, -1001, 41); ok {
|
|
t.Fatal("different administrator consumed pending action")
|
|
}
|
|
if _, ok := bot.takePending(token, -1001, 42); ok {
|
|
t.Fatal("an unauthorized attempt must invalidate the one-time action")
|
|
}
|
|
}
|
|
|
|
func TestTelegramMenuCallbackParsing(t *testing.T) {
|
|
prefix, token, operation, ok := parseTelegramMenuCallback("call:0123456789abcdef:answer")
|
|
if !ok || prefix != "call" || token != "0123456789abcdef" || operation != "answer" {
|
|
t.Fatalf("parsed callback = %q %q %q %t", prefix, token, operation, ok)
|
|
}
|
|
for _, invalid := range []string{"", "call:token", "unknown:token:op", "d::status"} {
|
|
if _, _, _, ok := parseTelegramMenuCallback(invalid); ok {
|
|
t.Fatalf("invalid callback %q was accepted", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelegramMenuPendingCanBeReusedButConfirmationCannot(t *testing.T) {
|
|
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
|
|
menuToken, err := bot.putPending(telegramPendingAction{
|
|
Kind: "menu_device", DeviceID: "EC20", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
|
|
t.Fatal("first menu lookup failed")
|
|
}
|
|
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
|
|
t.Fatal("menu token was unexpectedly consumed")
|
|
}
|
|
confirmToken, err := bot.putPending(telegramPendingAction{
|
|
Kind: "sms", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := bot.takePending(confirmToken, 1, 2); !ok {
|
|
t.Fatal("confirmation token lookup failed")
|
|
}
|
|
if _, ok := bot.takePending(confirmToken, 1, 2); ok {
|
|
t.Fatal("confirmation token was reusable")
|
|
}
|
|
}
|
|
|
|
func TestTelegramInputStateIsScopedAndCancelable(t *testing.T) {
|
|
bot := &telegramBot{inputs: make(map[string]telegramInputState)}
|
|
bot.setInput(telegramInputState{Kind: "sms_phone", DeviceID: "EC20", ChatID: 10, AdminID: 20})
|
|
if state, ok := bot.input(10, 20); !ok || state.DeviceID != "EC20" || state.Kind != "sms_phone" {
|
|
t.Fatalf("input state = %#v, %t", state, ok)
|
|
}
|
|
if _, ok := bot.input(10, 21); ok {
|
|
t.Fatal("another administrator read the input state")
|
|
}
|
|
bot.clearInput(10, 20)
|
|
if _, ok := bot.input(10, 20); ok {
|
|
t.Fatal("cleared input state remained available")
|
|
}
|
|
}
|
|
|
|
func TestFormatTelegramATIncludesFinalResult(t *testing.T) {
|
|
if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" {
|
|
t.Fatalf("formatTelegramAT(OK) = %q", got)
|
|
}
|
|
if got := formatTelegramAT(modem.Response{Lines: []string{"+CLCC: 1"}, Final: "OK"}); got != "+CLCC: 1\nOK" {
|
|
t.Fatalf("formatTelegramAT(lines) = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTelegramExecutesGuardedATForConfiguredDevice(t *testing.T) {
|
|
database, err := store.Open(context.Background(), ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
if err := database.UpsertDevice(context.Background(), store.Device{ID: "EC20", Name: "EC20"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bot := &telegramBot{server: &Server{
|
|
store: database,
|
|
devices: fakeDeviceController{
|
|
entry: device.Device{ID: "EC20", Discovered: true},
|
|
atResponse: modem.Response{Lines: []string{"+CSQ: 18,99"}, Final: "OK"},
|
|
},
|
|
}}
|
|
result, err := bot.executeATCommand(context.Background(), "EC20", "AT+CSQ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, expected := range []string{"设备:EC20", "> AT+CSQ", "+CSQ: 18,99", "OK"} {
|
|
if !strings.Contains(result, expected) {
|
|
t.Fatalf("AT result %q does not contain %q", result, expected)
|
|
}
|
|
}
|
|
if _, err := bot.executeATCommand(context.Background(), "EC20", "AT+CFUN=0"); err == nil {
|
|
t.Fatal("guarded AT command unexpectedly succeeded")
|
|
}
|
|
}
|
|
|
|
func TestTelegramExecutesInteractiveUSSDForConfiguredDevice(t *testing.T) {
|
|
database, err := store.Open(context.Background(), ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
if err := database.UpsertDevice(context.Background(), store.Device{ID: "EC20", Name: "EC20"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bot := &telegramBot{server: &Server{
|
|
store: database,
|
|
devices: fakeDeviceController{
|
|
entry: device.Device{ID: "EC20", Discovered: true},
|
|
ussdResult: device.USSDResult{
|
|
Code: "*100#", Text: "1. Balance\n2. Bundles", Status: "awaiting_input",
|
|
SessionID: "0123456789abcdef", Continueable: true,
|
|
},
|
|
},
|
|
}}
|
|
result, err := bot.executeUSSDCommand(context.Background(), "EC20", "*100#")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
formatted := formatTelegramUSSD("EC20", result)
|
|
for _, expected := range []string{
|
|
"设备:EC20", "状态:awaiting_input", "1. Balance", "请直接发送回复内容",
|
|
} {
|
|
if !strings.Contains(formatted, expected) {
|
|
t.Fatalf("USSD result %q does not contain %q", formatted, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelegramErrorsRedactBotTokens(t *testing.T) {
|
|
token := "1234567890:abcdefghijklmnopqrstuvwxyzABCDE"
|
|
err := errors.New(`Post "https://api.telegram.org/bot` + token + `/getUpdates": context canceled`)
|
|
redacted := redactTelegramError(err, token)
|
|
if strings.Contains(redacted.Error(), token) || !strings.Contains(redacted.Error(), "bot[REDACTED]") {
|
|
t.Fatalf("redacted error = %q", redacted)
|
|
}
|
|
}
|
|
|
|
func TestTelegramCallTransportFollowsConfiguredCardMode(t *testing.T) {
|
|
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true, Phase: vowifi.PhaseIMSReady}}
|
|
bot := &telegramBot{server: &Server{vowifi: controller}}
|
|
|
|
transport, gotController, err := bot.telegramCallTransport(
|
|
store.Device{ID: "EC20", VoWiFiEnabled: true},
|
|
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
|
|
)
|
|
if err != nil || transport != "vowifi" || gotController == nil {
|
|
t.Fatalf("VoWiFi route = %q, %#v, %v", transport, gotController, err)
|
|
}
|
|
|
|
transport, gotController, err = bot.telegramCallTransport(
|
|
store.Device{ID: "EC20", VoWiFiEnabled: false},
|
|
device.Device{Snapshot: &device.Snapshot{FlightMode: false}},
|
|
)
|
|
if err != nil || transport != "cellular" || gotController != nil {
|
|
t.Fatalf("cellular route = %q, %#v, %v", transport, gotController, err)
|
|
}
|
|
}
|
|
|
|
func TestTelegramCallTransportDoesNotFallBackFromUnreadyVoWiFi(t *testing.T) {
|
|
controller := &telegramTestCallController{state: vowifi.State{
|
|
Phase: vowifi.PhaseFailed, LastError: "SIP registration was rejected: SIP 403",
|
|
}}
|
|
bot := &telegramBot{server: &Server{vowifi: controller}}
|
|
_, _, err := bot.telegramCallTransport(
|
|
store.Device{ID: "EC20", VoWiFiEnabled: true},
|
|
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "SIP 403") {
|
|
t.Fatalf("unready VoWiFi route error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTelegramTimedVoWiFiCallUsesIMSAndHangsUpByCallID(t *testing.T) {
|
|
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true}}
|
|
controller.dialResult = vowifi.Call{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "dialing"}
|
|
controller.calls = []vowifi.Call{{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "active"}}
|
|
bot := &telegramBot{server: &Server{vowifi: controller}}
|
|
result, err := bot.executeTimedVoWiFiCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
|
|
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
|
|
}, controller)
|
|
if err != nil || !strings.Contains(result, "已接通") {
|
|
t.Fatalf("timed VoWiFi result = %q, %v", result, err)
|
|
}
|
|
if controller.dialed != "+447700900123" || len(controller.hungUp) != 1 || controller.hungUp[0] != "ims-call-1" {
|
|
t.Fatalf("IMS actions dial=%q hangup=%#v", controller.dialed, controller.hungUp)
|
|
}
|
|
}
|
|
|
|
func TestTelegramTimedCellularCallUsesATDCLCCAndATH(t *testing.T) {
|
|
commands := make([]string, 0, 3)
|
|
devices := fakeDeviceController{atHandler: func(command string) (modem.Response, error) {
|
|
commands = append(commands, command)
|
|
switch {
|
|
case strings.HasPrefix(command, "ATD"):
|
|
return modem.Response{Final: "OK"}, nil
|
|
case command == "AT+CLCC":
|
|
return modem.Response{Lines: []string{`+CLCC: 1,0,2,0,0,"+447700900123",145`}, Final: "OK"}, nil
|
|
case command == "ATH":
|
|
return modem.Response{Final: "OK"}, nil
|
|
default:
|
|
return modem.Response{}, errors.New("unexpected command")
|
|
}
|
|
}}
|
|
bot := &telegramBot{server: &Server{devices: devices}}
|
|
result, err := bot.executeTimedCellularCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
|
|
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
|
|
}, "physical")
|
|
if err != nil || !strings.Contains(result, "正在拨号") {
|
|
t.Fatalf("timed cellular result = %q, %v", result, err)
|
|
}
|
|
joined := strings.Join(commands, ",")
|
|
for _, expected := range []string{"ATD+447700900123;", "AT+CLCC", "ATH"} {
|
|
if !strings.Contains(joined, expected) {
|
|
t.Fatalf("commands %q omit %q", joined, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelegramVoWiFiFailureIncludesSIPDiagnostic(t *testing.T) {
|
|
err := telegramVoWiFiCallFailure(vowifi.Call{State: "failed", SIPCode: 403, Reason: "Forbidden"})
|
|
if !strings.Contains(err.Error(), "SIP 403") || !strings.Contains(err.Error(), "Forbidden") {
|
|
t.Fatalf("VoWiFi diagnostic = %q", err)
|
|
}
|
|
}
|
|
|
|
func TestTelegramVoWiFi487IsReportedAsCancelledOutcome(t *testing.T) {
|
|
result, err := telegramVoWiFiCallOutcome("888", vowifi.Call{
|
|
State: "failed", SIPCode: 487, Reason: "Request Terminated",
|
|
})
|
|
if err != nil || !strings.Contains(result, "取消或终止") || !strings.Contains(result, "SIP 487") {
|
|
t.Fatalf("487 outcome = %q, %v", result, err)
|
|
}
|
|
}
|
|
|
|
type telegramTestCallController struct {
|
|
state vowifi.State
|
|
calls []vowifi.Call
|
|
dialResult vowifi.Call
|
|
dialErr error
|
|
dialed string
|
|
hungUp []string
|
|
}
|
|
|
|
func (controller *telegramTestCallController) State(string) (vowifi.State, error) {
|
|
return controller.state, nil
|
|
}
|
|
|
|
func (controller *telegramTestCallController) RequestEnabled(string, bool) (vowifi.State, error) {
|
|
return controller.state, nil
|
|
}
|
|
|
|
func (controller *telegramTestCallController) RequestReconnect(string) (vowifi.State, error) {
|
|
return controller.state, nil
|
|
}
|
|
|
|
func (controller *telegramTestCallController) Calls(string) ([]vowifi.Call, error) {
|
|
return append([]vowifi.Call(nil), controller.calls...), nil
|
|
}
|
|
|
|
func (controller *telegramTestCallController) DialCall(_ context.Context, _ string, number string) (vowifi.Call, error) {
|
|
controller.dialed = number
|
|
return controller.dialResult, controller.dialErr
|
|
}
|
|
|
|
func (controller *telegramTestCallController) AnswerCall(_ context.Context, _ string, id string) (vowifi.Call, error) {
|
|
for _, call := range controller.calls {
|
|
if call.ID == id {
|
|
call.State = "active"
|
|
return call, nil
|
|
}
|
|
}
|
|
return vowifi.Call{}, errors.New("call not found")
|
|
}
|
|
|
|
func (controller *telegramTestCallController) HangupCall(_ context.Context, _ string, id string) error {
|
|
controller.hungUp = append(controller.hungUp, id)
|
|
return nil
|
|
}
|