Skip to content

Commit e55cf10

Browse files
authored
feat: add integration test infrastructure with E2E SMS tests (#879)
* Added integration tests * Fix readme * Use stable go version * Fix api
1 parent 3424a01 commit e55cf10

23 files changed

Lines changed: 1736 additions & 16 deletions

.github/workflows/api.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: api
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
permissions:
12+
contents: read
13+
id-token: write
14+
15+
jobs:
16+
Test:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout 🛎
20+
uses: actions/checkout@v6
21+
22+
- name: Set up Go
23+
uses: actions/setup-go@v6
24+
with:
25+
go-version: stable
26+
27+
- name: Generate Firebase credentials
28+
run: |
29+
bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json
30+
echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV
31+
32+
- name: Start Services
33+
working-directory: ./tests
34+
run: docker compose up -d --build
35+
36+
- name: Wait for services to be healthy
37+
working-directory: ./tests
38+
run: |
39+
echo "Waiting for API to be healthy..."
40+
for i in $(seq 1 40); do
41+
if docker compose exec api curl -sf http://localhost:8000/health >/dev/null 2>&1; then
42+
echo "API is healthy!"
43+
break
44+
fi
45+
if [ $i -eq 40 ]; then
46+
echo "API failed to become healthy"
47+
docker compose logs api
48+
exit 1
49+
fi
50+
echo "Attempt $i/40 - waiting 5s..."
51+
sleep 5
52+
done
53+
54+
- name: Seed Database
55+
working-directory: ./tests
56+
run: |
57+
echo "Waiting for seed container to finish..."
58+
docker compose wait seed || true
59+
sleep 2
60+
61+
- name: Run Integration Tests
62+
working-directory: ./tests
63+
run: go test -v -timeout 300s ./...
64+
65+
- name: Collect Logs on Failure
66+
if: failure()
67+
working-directory: ./tests
68+
run: |
69+
docker compose logs --tail 200
70+
71+
- name: Stop Services
72+
if: always()
73+
working-directory: ./tests
74+
run: docker compose down -v
75+
76+
Deploy:
77+
runs-on: ubuntu-latest
78+
needs: Test
79+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
80+
steps:
81+
- name: Authenticate to Google Cloud
82+
uses: google-github-actions/auth@v2
83+
with:
84+
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
85+
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
86+
87+
- name: Set up Cloud SDK
88+
uses: google-github-actions/setup-gcloud@v3
89+
90+
- name: Trigger Cloud Build Deploy 🚀
91+
run: |
92+
BUILD_ID=$(gcloud builds triggers run api-httpsms-com \
93+
--region=global \
94+
--project=httpsms-86c51 \
95+
--sha=${{ github.sha }} \
96+
--format="value(metadata.build.id)")
97+
echo "Build ID: $BUILD_ID"
98+
echo "Streaming build logs..."
99+
gcloud builds log "$BUILD_ID" --region=global --project=httpsms-86c51 --stream
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: ci
1+
name: web
22

33
on:
44
push:
@@ -25,7 +25,7 @@ jobs:
2525
- uses: pnpm/action-setup@v6
2626
name: Install pnpm
2727
with:
28-
version: 9
28+
version: 10
2929

3030
- name: Install dependencies 📦
3131
run: pnpm install

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@
66
android/app/debug/
77
*main.exe*
88
android/app/release/
9+
10+
tests/firebase-credentials.json
11+
tests/emulator/emulator.exe

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# httpSMS
22

3-
[![Build](https://github.com/NdoleStudio/httpsms/actions/workflows/ci.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/ci.yml)
3+
[![Web](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml)
4+
[![API](https://github.com/NdoleStudio/httpsms/actions/workflows/api.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/api.yml)
45
[![GitHub contributors](https://img.shields.io/github/contributors/NdoleStudio/httpsms)](https://github.com/NdoleStudio/httpsms/graphs/contributors)
56
[![GitHub license](https://img.shields.io/github/license/NdoleStudio/httpsms?color=brightgreen)](https://github.com/NdoleStudio/httpsms/blob/master/LICENSE)
67
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
@@ -43,6 +44,7 @@ Quick Start Guide 👉 [https://docs.httpsms.com](https://docs.httpsms.com)
4344
- [6. Build and Run](#6-build-and-run)
4445
- [7. Create the System User](#7-create-the-system-user)
4546
- [8. Build the Android App.](#8-build-the-android-app)
47+
- [Integration Testing](#integration-testing)
4648
- [License](#license)
4749

4850
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -255,6 +257,26 @@ docker compose up --build
255257

256258
- Before building the Android app in [Android Studio](https://developer.android.com/studio), you need to replace the `google-services.json` file in the `android/app` directory with the file which you got from step 1. You need to do this for the firebase FCM messages to work properly.
257259

260+
## Integration Testing
261+
262+
The project includes end-to-end integration tests that validate the complete SMS send/receive lifecycle. Tests run the full stack (API, PostgreSQL, Redis) in Docker alongside a phone emulator that simulates an Android device.
263+
264+
📖 **Full documentation:** [`tests/README.md`](tests/README.md)
265+
266+
**Quick run:**
267+
268+
```bash
269+
cd tests
270+
bash generate-firebase-credentials.sh
271+
export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json)
272+
docker compose up -d --build --wait
273+
docker compose wait seed && sleep 2
274+
go test -v -timeout 120s ./...
275+
docker compose down -v
276+
```
277+
278+
Integration tests also run automatically in CI on every push/PR to `main`.
279+
258280
## License
259281

260282
This project is licensed under the GNU AFFERO GENERAL PUBLIC LICENSE Version 3 - see the [LICENSE](LICENSE) file for details

api/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$GI
2121

2222
FROM alpine:latest
2323

24-
RUN addgroup -S http-sms && adduser -S http-sms -G http-sms
24+
RUN apk add --no-cache curl && addgroup -S http-sms && adduser -S http-sms -G http-sms
2525

2626
USER http-sms
2727
WORKDIR /home/http-sms

api/cmd/fcm/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func main() {
1818
}
1919

2020
container := di.NewContainer(os.Getenv("GCP_PROJECT_ID"), "")
21-
client := container.FirebaseMessagingClient()
21+
client := container.FCMClient()
2222

2323
result, err := client.Send(context.Background(), &messaging.Message{
2424
Data: map[string]string{

api/pkg/di/container.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ import (
4747
"go.opentelemetry.io/otel/sdk/resource"
4848
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
4949

50-
"firebase.google.com/go/messaging"
5150
"github.com/hirosassa/zerodriver"
5251
"github.com/rs/zerolog"
5352
"go.opentelemetry.io/otel/sdk/trace"
@@ -176,6 +175,11 @@ func (container *Container) App() (app *fiber.App) {
176175

177176
app = fiber.New()
178177

178+
// Health check endpoint registered before middleware for reliable Docker health checks
179+
app.Get("/health", func(c *fiber.Ctx) error {
180+
return c.SendStatus(fiber.StatusOK)
181+
})
182+
179183
if os.Getenv("USE_HTTP_LOGGER") == "true" {
180184
app.Use(fiberLogger.New())
181185
}
@@ -397,7 +401,8 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
397401
// FirebaseApp creates a new instance of firebase.App
398402
func (container *Container) FirebaseApp() (app *firebase.App) {
399403
container.logger.Debug(fmt.Sprintf("creating %T", app))
400-
app, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
404+
405+
app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials()))
401406
if err != nil {
402407
msg := "cannot initialize firebase application"
403408
container.logger.Fatal(stacktrace.Propagate(err, msg))
@@ -419,8 +424,10 @@ func (container *Container) Cache() cache.Cache {
419424
if err != nil {
420425
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot parse redis url [%s]", os.Getenv("REDIS_URL"))))
421426
}
422-
opt.TLSConfig = &tls.Config{
423-
MinVersion: tls.VersionTLS12,
427+
if strings.HasPrefix(os.Getenv("REDIS_URL"), "rediss://") {
428+
opt.TLSConfig = &tls.Config{
429+
MinVersion: tls.VersionTLS12,
430+
}
424431
}
425432

426433
redisClient := redis.NewClient(opt)
@@ -506,15 +513,27 @@ func (container *Container) CloudTaskEventsQueue() (queue services.PushQueue) {
506513
)
507514
}
508515

509-
// FirebaseMessagingClient creates a new instance of messaging.Client
510-
func (container *Container) FirebaseMessagingClient() (client *messaging.Client) {
511-
container.logger.Debug(fmt.Sprintf("creating %T", client))
516+
// FCMClient creates the appropriate FCM client based on configuration.
517+
// When FCM_ENDPOINT is set, it returns an EmulatorFCMClient that sends
518+
// notifications directly to the phone emulator via HTTP.
519+
// Otherwise, it returns a FirebaseFCMClient that uses the real Firebase SDK.
520+
func (container *Container) FCMClient() services.FCMClient {
521+
if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" {
522+
container.logger.Info(fmt.Sprintf("using emulator FCM client with endpoint: %s", fcmEndpoint))
523+
return services.NewEmulatorFCMClient(
524+
container.HTTPClient("emulator_fcm"),
525+
fcmEndpoint,
526+
container.Logger(),
527+
)
528+
}
529+
530+
container.logger.Debug("creating FirebaseFCMClient")
512531
messagingClient, err := container.FirebaseApp().Messaging(context.Background())
513532
if err != nil {
514533
msg := "cannot initialize firebase messaging client"
515534
container.logger.Fatal(stacktrace.Propagate(err, msg))
516535
}
517-
return messagingClient
536+
return services.NewFirebaseFCMClient(messagingClient)
518537
}
519538

520539
// FirebaseCredentials returns firebase credentials as bytes.
@@ -1588,7 +1607,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
15881607
return services.NewNotificationService(
15891608
container.Logger(),
15901609
container.Tracer(),
1591-
container.FirebaseMessagingClient(),
1610+
container.FCMClient(),
15921611
container.PhoneRepository(),
15931612
container.PhoneNotificationRepository(),
15941613
container.MessageSendScheduleRepository(),
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package services
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
11+
"firebase.google.com/go/messaging"
12+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
13+
"github.com/palantir/stacktrace"
14+
)
15+
16+
// EmulatorFCMClient sends FCM messages to the phone emulator via HTTP.
17+
type EmulatorFCMClient struct {
18+
httpClient *http.Client
19+
endpoint string
20+
logger telemetry.Logger
21+
}
22+
23+
// NewEmulatorFCMClient creates a new EmulatorFCMClient.
24+
func NewEmulatorFCMClient(httpClient *http.Client, endpoint string, logger telemetry.Logger) *EmulatorFCMClient {
25+
return &EmulatorFCMClient{
26+
httpClient: httpClient,
27+
endpoint: endpoint,
28+
logger: logger,
29+
}
30+
}
31+
32+
// emulatorFCMRequest is the payload sent to the emulator's FCM endpoint.
33+
type emulatorFCMRequest struct {
34+
Message *emulatorFCMMessage `json:"message"`
35+
}
36+
37+
type emulatorFCMMessage struct {
38+
Token string `json:"token"`
39+
Data map[string]string `json:"data,omitempty"`
40+
Android *emulatorAndroid `json:"android,omitempty"`
41+
}
42+
43+
type emulatorAndroid struct {
44+
Priority string `json:"priority,omitempty"`
45+
}
46+
47+
// emulatorFCMResponse is the response from the emulator.
48+
type emulatorFCMResponse struct {
49+
Name string `json:"name"`
50+
}
51+
52+
// Send sends a message to the emulator's FCM endpoint.
53+
func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
54+
payload := &emulatorFCMRequest{
55+
Message: &emulatorFCMMessage{
56+
Token: message.Token,
57+
Data: message.Data,
58+
},
59+
}
60+
if message.Android != nil {
61+
payload.Message.Android = &emulatorAndroid{
62+
Priority: message.Android.Priority,
63+
}
64+
}
65+
66+
body, err := json.Marshal(payload)
67+
if err != nil {
68+
return "", stacktrace.Propagate(err, "cannot marshal FCM request for emulator")
69+
}
70+
71+
url := fmt.Sprintf("%s/v1/projects/httpsms-test/messages:send", c.endpoint)
72+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
73+
if err != nil {
74+
return "", stacktrace.Propagate(err, "cannot create HTTP request for emulator FCM")
75+
}
76+
req.Header.Set("Content-Type", "application/json")
77+
78+
resp, err := c.httpClient.Do(req)
79+
if err != nil {
80+
return "", stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to emulator at [%s]", url))
81+
}
82+
defer resp.Body.Close()
83+
84+
respBody, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
return "", stacktrace.Propagate(err, "cannot read emulator FCM response body")
87+
}
88+
89+
if resp.StatusCode != http.StatusOK {
90+
return "", stacktrace.NewError("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody))
91+
}
92+
93+
var result emulatorFCMResponse
94+
if err = json.Unmarshal(respBody, &result); err != nil {
95+
return "", stacktrace.Propagate(err, "cannot decode emulator FCM response")
96+
}
97+
98+
c.logger.Info(fmt.Sprintf("emulator FCM sent successfully: %s", result.Name))
99+
return result.Name, nil
100+
}

api/pkg/services/fcm_client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package services
2+
3+
import (
4+
"context"
5+
6+
"firebase.google.com/go/messaging"
7+
)
8+
9+
// FCMClient is the interface for sending Firebase Cloud Messaging notifications.
10+
type FCMClient interface {
11+
// Send sends a message via FCM and returns the message name on success.
12+
Send(ctx context.Context, message *messaging.Message) (string, error)
13+
}
14+
15+
// FirebaseFCMClient wraps the real Firebase messaging.Client.
16+
type FirebaseFCMClient struct {
17+
client *messaging.Client
18+
}
19+
20+
// NewFirebaseFCMClient creates a new FirebaseFCMClient.
21+
func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient {
22+
return &FirebaseFCMClient{client: client}
23+
}
24+
25+
// Send sends a message via the real Firebase SDK.
26+
func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
27+
return c.client.Send(ctx, message)
28+
}

0 commit comments

Comments
 (0)