package modem import ( "context" "errors" "fmt" "io" "sync" "testing" "time" ) type transportStep struct { write string chunks []string } type transcriptTransport struct { mu sync.Mutex steps []transportStep chunks [][]byte pendingWrite string pendingChunks []string readTimeout time.Duration resetCount int closed bool unexpected error writePartial bool writeEvents chan string } func (transport *transcriptTransport) Write(payload []byte) (int, error) { transport.mu.Lock() defer transport.mu.Unlock() if transport.closed { return 0, io.ErrClosedPipe } if transport.pendingWrite != "" { if string(payload) != transport.pendingWrite { transport.unexpected = fmt.Errorf( "partial write %q, want %q", payload, transport.pendingWrite, ) return 0, transport.unexpected } for _, chunk := range transport.pendingChunks { transport.chunks = append(transport.chunks, []byte(chunk)) } transport.pendingWrite = "" transport.pendingChunks = nil return len(payload), nil } if len(transport.steps) == 0 { transport.unexpected = fmt.Errorf("unexpected write %q", payload) return 0, transport.unexpected } step := transport.steps[0] transport.steps = transport.steps[1:] if string(payload) != step.write { transport.unexpected = fmt.Errorf("write %q, want %q", payload, step.write) return 0, transport.unexpected } if transport.writeEvents != nil { select { case transport.writeEvents <- string(payload): default: } } if transport.writePartial && len(payload) > 1 { transport.writePartial = false count := len(payload) / 2 transport.pendingWrite = step.write[count:] transport.pendingChunks = append([]string(nil), step.chunks...) return count, nil } for _, chunk := range step.chunks { transport.chunks = append(transport.chunks, []byte(chunk)) } return len(payload), nil } func (transport *transcriptTransport) enqueue(chunks ...string) { transport.mu.Lock() for _, chunk := range chunks { transport.chunks = append(transport.chunks, []byte(chunk)) } transport.mu.Unlock() } func (transport *transcriptTransport) Read(buffer []byte) (int, error) { transport.mu.Lock() if transport.closed { transport.mu.Unlock() return 0, io.EOF } if len(transport.chunks) > 0 { chunk := transport.chunks[0] count := copy(buffer, chunk) if count == len(chunk) { transport.chunks = transport.chunks[1:] } else { transport.chunks[0] = chunk[count:] } transport.mu.Unlock() return count, nil } timeout := transport.readTimeout transport.mu.Unlock() if timeout <= 0 || timeout > 2*time.Millisecond { timeout = time.Millisecond } time.Sleep(timeout) return 0, nil } func (transport *transcriptTransport) Drain() error { return nil } func (transport *transcriptTransport) ResetInputBuffer() error { transport.mu.Lock() transport.chunks = nil transport.resetCount++ transport.mu.Unlock() return nil } func (transport *transcriptTransport) SetReadTimeout(timeout time.Duration) error { transport.mu.Lock() transport.readTimeout = timeout transport.mu.Unlock() return nil } func (transport *transcriptTransport) Close() error { transport.mu.Lock() transport.closed = true transport.mu.Unlock() return nil } func TestSessionSeparatesInterleavedURCs(t *testing.T) { transport := &transcriptTransport{steps: []transportStep{{ write: "AT+CSQ\r", chunks: []string{ "\r\nAT+CSQ\r\n+CMTI: \"SM\",7\r\n", "+CSQ: 24,99\r\nOK\r\n", }, }}} session := newTestSession(t, transport) response, err := session.Execute(context.Background(), "AT+CSQ") if err != nil { t.Fatalf("Execute: %v", err) } if got := response.Text(); got != "+CSQ: 24,99" { t.Fatalf("response = %q", got) } if len(response.URCs) != 1 || response.URCs[0] != `+CMTI: "SM",7` { t.Fatalf("URCs = %#v", response.URCs) } urc, err := session.WaitURC(context.Background(), func(line string) bool { return line == `+CMTI: "SM",7` }) if err != nil || urc == "" { t.Fatalf("WaitURC = %q, %v", urc, err) } } func TestSessionKeepsExpectedRegistrationLineInResponse(t *testing.T) { transport := &transcriptTransport{steps: []transportStep{{ write: "AT+CEREG?\r", chunks: []string{"\r\n+CEREG: 0,5\r\nOK\r\n"}, }}} session := newTestSession(t, transport) response, err := session.Execute(context.Background(), "AT+CEREG?") if err != nil { t.Fatalf("Execute: %v", err) } if response.Text() != "+CEREG: 0,5" || len(response.URCs) != 0 { t.Fatalf("response = %#v", response) } } func TestSessionQueuesCUSDThatArrivesBeforeOK(t *testing.T) { transport := &transcriptTransport{steps: []transportStep{{ write: "AT+CUSD=1,\"*100#\",15\r", chunks: []string{"\r\n+CUSD: 0,\"004F004B\",72\r\nOK\r\n"}, }}} session := newTestSession(t, transport) response, err := session.Execute(context.Background(), `AT+CUSD=1,"*100#",15`) if err != nil { t.Fatalf("Execute: %v", err) } if len(response.URCs) != 1 { t.Fatalf("URCs = %#v", response.URCs) } urc, err := session.WaitURC(context.Background(), func(line string) bool { return len(line) >= 6 && line[:6] == "+CUSD:" }) if err != nil || urc != `+CUSD: 0,"004F004B",72` { t.Fatalf("WaitURC = %q, %v", urc, err) } } func TestSessionReturnsTypedCommandError(t *testing.T) { transport := &transcriptTransport{steps: []transportStep{{ write: "AT+CPIN?\r", chunks: []string{"\r\n+CME ERROR: 10\r\n"}, }}} session := newTestSession(t, transport) _, err := session.Execute(context.Background(), "AT+CPIN?") var commandErr *CommandError if !errors.As(err, &commandErr) || commandErr.Final != "+CME ERROR: 10" { t.Fatalf("error = %#v", err) } } func TestSessionTimeoutResetsInputAndRejectsCommandInjection(t *testing.T) { transport := &transcriptTransport{steps: []transportStep{{write: "AT\r"}}} session, err := NewSession(transport, SessionOptions{ ReadTimeout: time.Millisecond, CommandTimeout: 15 * time.Millisecond, }) if err != nil { t.Fatal(err) } _, err = session.Execute(context.Background(), "AT") if !errors.Is(err, ErrCommandTimeout) { t.Fatalf("error = %v", err) } if transport.resetCount != 1 { t.Fatalf("reset count = %d", transport.resetCount) } if _, err := session.Execute(context.Background(), "AT\rAT+CFUN=0"); err == nil { t.Fatal("expected command delimiter rejection") } } func TestSessionHandlesPartialWrites(t *testing.T) { transport := &transcriptTransport{ writePartial: true, steps: []transportStep{{ write: "AT+CSQ\r", chunks: []string{"\r\n+CSQ: 1,99\r\nOK\r\n"}, }}, } session := newTestSession(t, transport) response, err := session.Execute(context.Background(), "AT+CSQ") if err != nil { t.Fatalf("Execute: %v", err) } if response.Text() != "+CSQ: 1,99" { t.Fatalf("response = %#v", response) } transport.mu.Lock() defer transport.mu.Unlock() if transport.pendingWrite != "" || len(transport.steps) != 0 || transport.unexpected != nil { t.Fatalf( "unfinished transcript: pending=%q steps=%d err=%v", transport.pendingWrite, len(transport.steps), transport.unexpected, ) } } func TestSessionExecutePromptQueuesURCsAndReturnsCMGS(t *testing.T) { const pdu = "00010005912143F50008044F60597D" transport := &transcriptTransport{steps: []transportStep{ { write: "AT+CMGS=14\r", chunks: []string{ "\r\nAT+CMGS=14\r\n+CMTI: \"SM\",7\r\n> ", }, }, {write: pdu}, { write: string([]byte{0x1a}), chunks: []string{ "\r\n" + pdu + "\r\n+CMGS: 42\r\n", "+CMTI: \"SM\",8\r\nOK\r\n", }, }, }} session := newTestSession(t, transport) response, err := session.ExecutePrompt( context.Background(), "AT+CMGS=14", []byte(pdu), ) if err != nil { t.Fatalf("ExecutePrompt: %v", err) } if response.Text() != "+CMGS: 42" || !response.OK() { t.Fatalf("response = %#v", response) } if len(response.URCs) != 2 { t.Fatalf("URCs = %#v", response.URCs) } for _, wanted := range []string{`+CMTI: "SM",7`, `+CMTI: "SM",8`} { line, waitErr := session.WaitURC( context.Background(), func(line string) bool { return line == wanted }, ) if waitErr != nil || line != wanted { t.Fatalf("WaitURC(%q) = %q, %v", wanted, line, waitErr) } } } func TestSessionExecutePromptTimeoutDoesNotWritePayload(t *testing.T) { transport := &transcriptTransport{ steps: []transportStep{{write: "AT+CMGS=5\r"}}, } session, err := NewSession(transport, SessionOptions{ ReadTimeout: time.Millisecond, CommandTimeout: 15 * time.Millisecond, }) if err != nil { t.Fatal(err) } _, err = session.ExecutePrompt( context.Background(), "AT+CMGS=5", []byte("001122"), ) if !errors.Is(err, ErrCommandTimeout) { t.Fatalf("error = %v", err) } transport.mu.Lock() defer transport.mu.Unlock() if transport.resetCount != 1 || len(transport.steps) != 0 || transport.unexpected != nil { t.Fatalf( "transport = reset %d, steps %d, error %v", transport.resetCount, len(transport.steps), transport.unexpected, ) } } func TestSessionExecutePromptSerializesConcurrentCommand(t *testing.T) { events := make(chan string, 4) transport := &transcriptTransport{ writeEvents: events, steps: []transportStep{ {write: "AT+CMGS=\"12345\"\r"}, {write: "HELLO"}, { write: string([]byte{0x1a}), chunks: []string{"\r\n+CMGS: 9\r\nOK\r\n"}, }, { write: "AT+CSQ\r", chunks: []string{"\r\n+CSQ: 20,99\r\nOK\r\n"}, }, }, } session := newTestSession(t, transport) promptResult := make(chan error, 1) go func() { _, err := session.ExecutePrompt( context.Background(), `AT+CMGS="12345"`, []byte("HELLO"), ) promptResult <- err }() if first := <-events; first != "AT+CMGS=\"12345\"\r" { t.Fatalf("first write = %q", first) } normalStarted := make(chan struct{}) normalResult := make(chan error, 1) go func() { close(normalStarted) _, err := session.Execute(context.Background(), "AT+CSQ") normalResult <- err }() <-normalStarted transport.enqueue("\r\n> ") if err := <-promptResult; err != nil { t.Fatalf("ExecutePrompt: %v", err) } if err := <-normalResult; err != nil { t.Fatalf("concurrent Execute: %v", err) } writes := []string{<-events, <-events, <-events} want := []string{"HELLO", string([]byte{0x1a}), "AT+CSQ\r"} for index := range want { if writes[index] != want[index] { t.Fatalf("write[%d] = %q, want %q", index, writes[index], want[index]) } } } func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) { transport := &transcriptTransport{} session := newTestSession(t, transport) if _, err := session.ExecutePrompt( context.Background(), "AT+CSQ", []byte("payload"), ); err == nil { t.Fatal("expected non-CMGS prompt command rejection") } if _, err := session.ExecutePrompt( context.Background(), "AT+CMGS=1", []byte{'A', 0x1a}, ); err == nil { t.Fatal("expected Ctrl-Z payload rejection") } } func newTestSession(t *testing.T, transport Transport) *Session { t.Helper() session, err := NewSession(transport, SessionOptions{ ReadTimeout: time.Millisecond, CommandTimeout: time.Second, }) if err != nil { t.Fatal(err) } return session }