Skip to content

Commit dd213fb

Browse files
AchoArnoldCopilot
andcommitted
feat(tests): add phone emulator service for integration tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5e2aae1 commit dd213fb

7 files changed

Lines changed: 271 additions & 0 deletions

File tree

tests/emulator/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM golang:1.22-alpine AS builder
2+
3+
WORKDIR /app
4+
COPY go.mod ./
5+
COPY *.go ./
6+
RUN go build -o emulator .
7+
8+
FROM alpine:latest
9+
RUN apk --no-cache add wget
10+
WORKDIR /app
11+
COPY --from=builder /app/emulator .
12+
EXPOSE 9090
13+
CMD ["./emulator"]

tests/emulator/emulator.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"os"
7+
"strings"
8+
)
9+
10+
// Emulator simulates an Android phone for integration tests
11+
type Emulator struct {
12+
apiBaseURL string
13+
phoneAPIKey string
14+
httpClient *http.Client
15+
}
16+
17+
// NewEmulator creates a new phone emulator
18+
func NewEmulator() *Emulator {
19+
apiBaseURL := os.Getenv("API_BASE_URL")
20+
if apiBaseURL == "" {
21+
log.Fatal("API_BASE_URL environment variable is required")
22+
}
23+
24+
phoneAPIKey := os.Getenv("PHONE_API_KEY")
25+
if phoneAPIKey == "" {
26+
log.Fatal("PHONE_API_KEY environment variable is required")
27+
}
28+
if !strings.HasPrefix(phoneAPIKey, "pk_") {
29+
log.Fatal("PHONE_API_KEY must start with pk_")
30+
}
31+
32+
return &Emulator{
33+
apiBaseURL: apiBaseURL,
34+
phoneAPIKey: phoneAPIKey,
35+
httpClient: &http.Client{},
36+
}
37+
}

tests/emulator/events.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log"
9+
"net/http"
10+
"time"
11+
)
12+
13+
// ProcessOutstandingMessages fetches outstanding messages and fires SENT/DELIVERED events.
14+
func (e *Emulator) ProcessOutstandingMessages() {
15+
time.Sleep(500 * time.Millisecond)
16+
17+
messages := e.fetchOutstandingMessages()
18+
for _, msg := range messages {
19+
msgID, ok := msg["id"].(string)
20+
if !ok {
21+
log.Printf("[EVENTS] message missing id field")
22+
continue
23+
}
24+
25+
log.Printf("[EVENTS] processing message %s", msgID)
26+
27+
time.Sleep(200 * time.Millisecond)
28+
e.fireEvent(msgID, "SENT")
29+
30+
time.Sleep(200 * time.Millisecond)
31+
e.fireEvent(msgID, "DELIVERED")
32+
}
33+
}
34+
35+
// fetchOutstandingMessages calls GET /v1/messages/outstanding.
36+
func (e *Emulator) fetchOutstandingMessages() []map[string]interface{} {
37+
url := fmt.Sprintf("%s/v1/messages/outstanding", e.apiBaseURL)
38+
39+
req, err := http.NewRequest(http.MethodGet, url, nil)
40+
if err != nil {
41+
log.Printf("[EVENTS] error creating request: %v", err)
42+
return nil
43+
}
44+
req.Header.Set("x-api-key", e.phoneAPIKey)
45+
46+
resp, err := e.httpClient.Do(req)
47+
if err != nil {
48+
log.Printf("[EVENTS] error fetching outstanding: %v", err)
49+
return nil
50+
}
51+
defer resp.Body.Close()
52+
53+
body, err := io.ReadAll(resp.Body)
54+
if err != nil {
55+
log.Printf("[EVENTS] error reading response: %v", err)
56+
return nil
57+
}
58+
59+
if resp.StatusCode != http.StatusOK {
60+
log.Printf("[EVENTS] outstanding returned %d: %s", resp.StatusCode, string(body))
61+
return nil
62+
}
63+
64+
var result map[string]interface{}
65+
if err := json.Unmarshal(body, &result); err != nil {
66+
log.Printf("[EVENTS] error parsing response: %v", err)
67+
return nil
68+
}
69+
70+
data, ok := result["data"].([]interface{})
71+
if !ok {
72+
log.Printf("[EVENTS] no data array in response")
73+
return nil
74+
}
75+
76+
var messages []map[string]interface{}
77+
for _, item := range data {
78+
if msg, ok := item.(map[string]interface{}); ok {
79+
messages = append(messages, msg)
80+
}
81+
}
82+
83+
log.Printf("[EVENTS] found %d outstanding messages", len(messages))
84+
return messages
85+
}
86+
87+
// fireEvent posts a message event (SENT or DELIVERED).
88+
func (e *Emulator) fireEvent(messageID, eventType string) {
89+
url := fmt.Sprintf("%s/v1/messages/%s/events", e.apiBaseURL, messageID)
90+
91+
payload := map[string]interface{}{
92+
"event_name": eventType,
93+
"timestamp": time.Now().UTC().Format(time.RFC3339),
94+
}
95+
96+
body, err := json.Marshal(payload)
97+
if err != nil {
98+
log.Printf("[EVENTS] error marshaling event: %v", err)
99+
return
100+
}
101+
102+
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
103+
if err != nil {
104+
log.Printf("[EVENTS] error creating request: %v", err)
105+
return
106+
}
107+
req.Header.Set("Content-Type", "application/json")
108+
req.Header.Set("x-api-key", e.phoneAPIKey)
109+
110+
resp, err := e.httpClient.Do(req)
111+
if err != nil {
112+
log.Printf("[EVENTS] error firing %s event: %v", eventType, err)
113+
return
114+
}
115+
defer resp.Body.Close()
116+
117+
respBody, _ := io.ReadAll(resp.Body)
118+
log.Printf("[EVENTS] %s event for %s: %d - %s", eventType, messageID, resp.StatusCode, string(respBody))
119+
}

tests/emulator/fcm_handler.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"log"
7+
"net/http"
8+
)
9+
10+
// HandleFCM receives FCM push notification requests from the API.
11+
// After receiving a push, it triggers SENT and DELIVERED events asynchronously.
12+
func (e *Emulator) HandleFCM(w http.ResponseWriter, r *http.Request) {
13+
log.Printf("[FCM] %s %s", r.Method, r.URL.Path)
14+
15+
body, err := io.ReadAll(r.Body)
16+
if err != nil {
17+
log.Printf("[FCM] error reading body: %v", err)
18+
http.Error(w, "bad request", http.StatusBadRequest)
19+
return
20+
}
21+
defer r.Body.Close()
22+
23+
log.Printf("[FCM] body: %s", string(body))
24+
25+
var fcmRequest map[string]interface{}
26+
if err := json.Unmarshal(body, &fcmRequest); err != nil {
27+
log.Printf("[FCM] error parsing body: %v", err)
28+
}
29+
30+
response := map[string]interface{}{
31+
"name": "projects/httpsms-test/messages/fake-message-id",
32+
}
33+
34+
w.Header().Set("Content-Type", "application/json")
35+
_ = json.NewEncoder(w).Encode(response)
36+
37+
go e.ProcessOutstandingMessages()
38+
}

tests/emulator/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/NdoleStudio/httpsms/tests/emulator
2+
3+
go 1.22

tests/emulator/main.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"os"
7+
)
8+
9+
func main() {
10+
emulator := NewEmulator()
11+
12+
port := os.Getenv("PORT")
13+
if port == "" {
14+
port = "9090"
15+
}
16+
17+
mux := http.NewServeMux()
18+
19+
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
20+
w.WriteHeader(http.StatusOK)
21+
_, _ = w.Write([]byte("ok"))
22+
})
23+
24+
mux.HandleFunc("/token", emulator.HandleToken)
25+
mux.HandleFunc("/v1/projects/", emulator.HandleFCM)
26+
27+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
28+
log.Printf("[CATCH-ALL] %s %s", r.Method, r.URL.Path)
29+
w.WriteHeader(http.StatusOK)
30+
_, _ = w.Write([]byte("ok"))
31+
})
32+
33+
log.Printf("Emulator starting on port %s", port)
34+
log.Printf("API_BASE_URL: %s", emulator.apiBaseURL)
35+
36+
if err := http.ListenAndServe(":"+port, mux); err != nil {
37+
log.Fatalf("server failed: %v", err)
38+
}
39+
}

tests/emulator/token_handler.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"log"
6+
"net/http"
7+
)
8+
9+
// HandleToken serves a fake OAuth2 token response.
10+
// The Firebase SDK calls this endpoint (from token_uri in credentials) before sending FCM messages.
11+
func (e *Emulator) HandleToken(w http.ResponseWriter, r *http.Request) {
12+
log.Printf("[TOKEN] %s %s", r.Method, r.URL.Path)
13+
14+
response := map[string]interface{}{
15+
"access_token": "fake-access-token",
16+
"token_type": "Bearer",
17+
"expires_in": 3600,
18+
}
19+
20+
w.Header().Set("Content-Type", "application/json")
21+
_ = json.NewEncoder(w).Encode(response)
22+
}

0 commit comments

Comments
 (0)