diff --git a/.gitignore b/.gitignore index 14594515..c6054173 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store # Added by code-review-graph .code-review-graph/ +build/ +services/ibl_merge_report_service/ibl-merge-report-service diff --git a/docs/superpowers/plans/2026-05-29-ibl-merge-report-service.md b/docs/superpowers/plans/2026-05-29-ibl-merge-report-service.md new file mode 100644 index 00000000..ea071af0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-ibl-merge-report-service.md @@ -0,0 +1,878 @@ +# ibl_merge_report_service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone Go HTTP service (`ibl_merge_report_service`) that fetches multiple PDF URLs, merges them into one PDF, and streams the result — called internally by the one-api-lab PHP gateway. + +**Architecture:** The service exposes a single `POST /merge` endpoint. It validates the `X-Internal-Secret` request header, parses the JSON body, fetches each PDF URL sequentially, merges them with pdfcpu, then writes the merged PDF as `application/pdf`. If any source fetch fails, the entire request fails with a mapped HTTP status code. No files are cached on disk. + +**Tech Stack:** Go 1.21+, `github.com/pdfcpu/pdfcpu` for in-memory PDF merging, standard library `net/http`. + +--- + +## File Map + +| File | Responsibility | +|------|---------------| +| `services/ibl_merge_report_service/go.mod` | Module definition | +| `services/ibl_merge_report_service/main.go` | Entry point — read env config, register route, start HTTP server | +| `services/ibl_merge_report_service/handler.go` | `mergeRequest` struct, `mergeHandler`, validation, `writeError` | +| `services/ibl_merge_report_service/handler_test.go` | Unit tests for all handler branches using a mock `mergeFunc` | +| `services/ibl_merge_report_service/merger.go` | `fetchPDF`, `mergePDFs`, `fetchError` type | +| `services/ibl_merge_report_service/merger_test.go` | httptest-based tests for `fetchPDF` error paths | +| `scripts/build-merge-service.sh` | Cross-compile for Linux amd64, output binary to `up/` | +| `services/ibl_merge_report_service/ibl-merge-report.service` | Systemd unit template for IBL server | + +--- + +### Task 1: Scaffold — module init + empty stubs + +**Files:** +- Create: `services/ibl_merge_report_service/go.mod` +- Create: `services/ibl_merge_report_service/main.go` +- Create: `services/ibl_merge_report_service/handler.go` +- Create: `services/ibl_merge_report_service/merger.go` + +- [ ] **Step 1.1: Create go.mod** + +```bash +mkdir -p /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/services/ibl_merge_report_service +``` + +`services/ibl_merge_report_service/go.mod`: +``` +module ibl-merge-report-service + +go 1.21 +``` + +- [ ] **Step 1.2: Create handler.go stub** + +`services/ibl_merge_report_service/handler.go`: +```go +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" +) + +type mergeRequest struct { + Name string `json:"name"` + URLs []string `json:"urls"` + MergeRequestID int64 `json:"mergeRequestID"` + TOrderHeaderID int64 `json:"T_OrderHeaderID"` +} + +type mergeFunc func(urls []string) ([]byte, error) + +type mergeHandler struct { + secret string + merge mergeFunc +} + +func newMergeHandler(secret string, merge mergeFunc) http.Handler { + return &mergeHandler{secret: secret, merge: merge} +} + +func (h *mergeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // TODO: implement in Task 3 + writeError(w, http.StatusNotImplemented, "not implemented") +} + +func validateMergeRequest(req *mergeRequest) error { + // TODO: implement in Task 2 + return nil +} + +func writeError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// suppress unused-import errors until tasks fill in usage +var _ = errors.As +var _ = fmt.Errorf +``` + +- [ ] **Step 1.3: Create merger.go stub** + +`services/ibl_merge_report_service/merger.go`: +```go +package main + +import ( + "fmt" + "net/http" + "time" +) + +var httpClient = &http.Client{Timeout: 25 * time.Second} + +type fetchError struct { + URL string + HTTPStatus int // 0 = network/timeout +} + +func (e *fetchError) Error() string { + if e.HTTPStatus != 0 { + return fmt.Sprintf("source %s returned HTTP %d", e.URL, e.HTTPStatus) + } + return fmt.Sprintf("failed to fetch source %s", e.URL) +} + +func fetchPDF(url string) ([]byte, error) { + // TODO: implement in Task 4 + return nil, fmt.Errorf("not implemented") +} + +func mergePDFs(urls []string) ([]byte, error) { + // TODO: implement in Task 5 + return nil, fmt.Errorf("not implemented") +} +``` + +- [ ] **Step 1.4: Create main.go** + +`services/ibl_merge_report_service/main.go`: +```go +package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + secret := os.Getenv("MERGE_INTERNAL_SECRET") + if secret == "" { + secret = "ibl-merge-secret" + } + addr := os.Getenv("MERGE_LISTEN_ADDR") + if addr == "" { + addr = "127.0.0.1:8005" + } + + mux := http.NewServeMux() + mux.HandleFunc("/merge", func(w http.ResponseWriter, r *http.Request) { + newMergeHandler(secret, mergePDFs).ServeHTTP(w, r) + }) + + log.Printf("ibl_merge_report_service listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + fmt.Fprintf(os.Stderr, "fatal: %v\n", err) + os.Exit(1) + } +} +``` + +- [ ] **Step 1.5: Add pdfcpu dependency and verify build** + +```bash +cd services/ibl_merge_report_service && go get github.com/pdfcpu/pdfcpu@latest && go mod tidy +``` + +```bash +cd services/ibl_merge_report_service && go build ./... +``` + +Expected: builds without errors (stubs compile). + +--- + +### Task 2: Validate request TDD + +**Files:** +- Modify: `services/ibl_merge_report_service/handler.go` — implement `validateMergeRequest` +- Create: `services/ibl_merge_report_service/handler_test.go` + +- [ ] **Step 2.1: Write failing tests for validateMergeRequest** + +`services/ibl_merge_report_service/handler_test.go`: +```go +package main + +import ( + "testing" +) + +func TestValidateMergeRequest_MissingName(t *testing.T) { + req := &mergeRequest{URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestValidateMergeRequest_EmptyURLs(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{}, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for empty urls") + } +} + +func TestValidateMergeRequest_NilURLs(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: nil, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for nil urls") + } +} + +func TestValidateMergeRequest_MissingMergeRequestID(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 0, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for missing mergeRequestID") + } +} + +func TestValidateMergeRequest_MissingTOrderHeaderID(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 0} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for missing T_OrderHeaderID") + } +} + +func TestValidateMergeRequest_Valid(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} +``` + +- [ ] **Step 2.2: Run tests — verify they fail** + +```bash +cd services/ibl_merge_report_service && go test ./... -run TestValidate -v +``` + +Expected: `TestValidateMergeRequest_Valid` fails (stub returns nil for everything). + +- [ ] **Step 2.3: Implement validateMergeRequest** + +Replace the `validateMergeRequest` stub in `handler.go`: +```go +func validateMergeRequest(req *mergeRequest) error { + if req.Name == "" { + return fmt.Errorf("name is required") + } + if len(req.URLs) == 0 { + return fmt.Errorf("urls must not be empty") + } + if req.MergeRequestID <= 0 { + return fmt.Errorf("mergeRequestID is required") + } + if req.TOrderHeaderID <= 0 { + return fmt.Errorf("T_OrderHeaderID is required") + } + return nil +} +``` + +Remove the now-unused stub lines at the bottom of handler.go: +```go +// Remove these two lines: +var _ = errors.As +var _ = fmt.Errorf +``` + +- [ ] **Step 2.4: Run tests — verify they pass** + +```bash +cd services/ibl_merge_report_service && go test ./... -run TestValidate -v +``` + +Expected: all 6 `TestValidateMergeRequest_*` tests PASS. + +--- + +### Task 3: Handler ServeHTTP TDD + +**Files:** +- Modify: `services/ibl_merge_report_service/handler.go` — implement `ServeHTTP` +- Modify: `services/ibl_merge_report_service/handler_test.go` — add handler tests + +- [ ] **Step 3.1: Write failing handler tests** + +Append to `handler_test.go`: +```go +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" +) + +func TestHandler_WrongMethod(t *testing.T) { + h := newMergeHandler("secret", nil) + req := httptest.NewRequest(http.MethodGet, "/merge", nil) + req.Header.Set("X-Internal-Secret", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} + +func TestHandler_WrongSecret(t *testing.T) { + h := newMergeHandler("correct", nil) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{}")) + req.Header.Set("X-Internal-Secret", "wrong") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestHandler_InvalidJSON(t *testing.T) { + h := newMergeHandler("s", nil) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{bad")) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandler_ValidationError(t *testing.T) { + h := newMergeHandler("s", nil) + body, _ := json.Marshal(mergeRequest{Name: "", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", w.Code) + } +} + +func TestHandler_FetchError404(t *testing.T) { + mockMerge := func(urls []string) ([]byte, error) { + return nil, &fetchError{URL: urls[0], HTTPStatus: http.StatusNotFound} + } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestHandler_FetchErrorNetwork(t *testing.T) { + mockMerge := func(urls []string) ([]byte, error) { + return nil, &fetchError{URL: urls[0], HTTPStatus: 0} + } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", w.Code) + } +} + +func TestHandler_MergeError(t *testing.T) { + mockMerge := func(urls []string) ([]byte, error) { + return nil, fmt.Errorf("pdfcpu: corrupt PDF") + } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +func TestHandler_Success(t *testing.T) { + fakeBytes := []byte("%PDF-1.4 fake-merged") + mockMerge := func(urls []string) ([]byte, error) { return fakeBytes, nil } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{ + Name: "result.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/pdf" { + t.Fatalf("expected application/pdf, got %s", ct) + } + if !bytes.Equal(w.Body.Bytes(), fakeBytes) { + t.Fatal("response body does not match expected PDF bytes") + } +} +``` + +The top of `handler_test.go` must have a single `import` block with all imports. Replace the import at the top of the file with: + +```go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) +``` + +- [ ] **Step 3.2: Run tests — verify they fail** + +```bash +cd services/ibl_merge_report_service && go test ./... -run TestHandler -v +``` + +Expected: all 8 `TestHandler_*` tests FAIL (stub returns 501). + +- [ ] **Step 3.3: Implement ServeHTTP** + +Replace the stub `ServeHTTP` in `handler.go` with: + +```go +func (h *mergeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + if r.Header.Get("X-Internal-Secret") != h.secret { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req mergeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := validateMergeRequest(&req); err != nil { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + pdfBytes, err := h.merge(req.URLs) + if err != nil { + var fe *fetchError + if errors.As(err, &fe) { + if fe.HTTPStatus == http.StatusNotFound { + writeError(w, http.StatusNotFound, "source PDF not found") + return + } + writeError(w, http.StatusBadGateway, "source PDF unavailable") + return + } + writeError(w, http.StatusInternalServerError, "merge failed") + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `inline; filename="`+req.Name+`"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(pdfBytes) +} +``` + +- [ ] **Step 3.4: Run tests — verify all pass** + +```bash +cd services/ibl_merge_report_service && go test ./... -run TestHandler -v +``` + +Expected: all 8 `TestHandler_*` tests PASS. + +- [ ] **Step 3.5: Commit** + +```bash +cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab +git add services/ibl_merge_report_service/ +git commit -m "TASKCODE - scaffold ibl_merge_report_service with handler logic and tests" +``` + +(Replace TASKCODE with the actual task code from user before committing.) + +--- + +### Task 4: fetchPDF TDD + +**Files:** +- Modify: `services/ibl_merge_report_service/merger.go` — implement `fetchPDF` +- Create: `services/ibl_merge_report_service/merger_test.go` + +- [ ] **Step 4.1: Write failing fetchPDF tests** + +`services/ibl_merge_report_service/merger_test.go`: +```go +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchPDF_Returns404Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := fetchPDF(srv.URL) + if err == nil { + t.Fatal("expected error for 404 response") + } + + var fe *fetchError + if !errors.As(err, &fe) { + t.Fatalf("expected *fetchError, got %T: %v", err, err) + } + if fe.HTTPStatus != http.StatusNotFound { + t.Fatalf("expected HTTPStatus 404, got %d", fe.HTTPStatus) + } +} + +func TestFetchPDF_Returns500Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := fetchPDF(srv.URL) + if err == nil { + t.Fatal("expected error for 500 response") + } + + var fe *fetchError + if !errors.As(err, &fe) { + t.Fatalf("expected *fetchError, got %T", err) + } + if fe.HTTPStatus != http.StatusInternalServerError { + t.Fatalf("expected HTTPStatus 500, got %d", fe.HTTPStatus) + } +} + +func TestFetchPDF_ReturnsBytes(t *testing.T) { + expected := []byte("%PDF-1.4 test content") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(expected) + })) + defer srv.Close() + + got, err := fetchPDF(srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != string(expected) { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestFetchPDF_NetworkError(t *testing.T) { + // Port that's almost certainly not listening + _, err := fetchPDF("http://127.0.0.1:19999/notexist") + if err == nil { + t.Fatal("expected error for unreachable host") + } + var fe *fetchError + if !errors.As(err, &fe) { + t.Fatalf("expected *fetchError, got %T: %v", err, err) + } + if fe.HTTPStatus != 0 { + t.Fatalf("expected HTTPStatus 0 for network error, got %d", fe.HTTPStatus) + } +} +``` + +- [ ] **Step 4.2: Run tests — verify they fail** + +```bash +cd services/ibl_merge_report_service && go test ./... -run TestFetchPDF -v +``` + +Expected: all 4 `TestFetchPDF_*` tests FAIL (stub returns generic error). + +- [ ] **Step 4.3: Implement fetchPDF** + +Replace the `fetchPDF` stub in `merger.go`: + +```go +func fetchPDF(url string) ([]byte, error) { + resp, err := httpClient.Get(url) + if err != nil { + return nil, &fetchError{URL: url, HTTPStatus: 0} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, &fetchError{URL: url, HTTPStatus: resp.StatusCode} + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &fetchError{URL: url, HTTPStatus: 0} + } + return data, nil +} +``` + +Add `"io"` to the import block in `merger.go`. + +- [ ] **Step 4.4: Run tests — verify all pass** + +```bash +cd services/ibl_merge_report_service && go test ./... -run TestFetchPDF -v +``` + +Expected: all 4 `TestFetchPDF_*` tests PASS. + +--- + +### Task 5: mergePDFs implementation + +**Files:** +- Modify: `services/ibl_merge_report_service/merger.go` — implement `mergePDFs` + +No unit test is written for `mergePDFs` because testing requires valid PDF binaries which are awkward to embed. Error propagation from `fetchPDF` is already covered by `TestFetchPDF_*`. End-to-end correctness is verified manually in Task 8. + +- [ ] **Step 5.1: Implement mergePDFs** + +Replace the `mergePDFs` stub in `merger.go`: + +```go +func mergePDFs(urls []string) ([]byte, error) { + readers := make([]io.ReadSeeker, 0, len(urls)) + for _, url := range urls { + data, err := fetchPDF(url) + if err != nil { + return nil, err + } + readers = append(readers, bytes.NewReader(data)) + } + + var buf bytes.Buffer + if err := api.MergeRaw(readers, &buf, nil); err != nil { + return nil, fmt.Errorf("PDF merge error: %w", err) + } + return buf.Bytes(), nil +} +``` + +Add `"bytes"` and `"github.com/pdfcpu/pdfcpu/pkg/api"` to the import block in `merger.go`. + +Remove the now-unused `"time"` import only if `httpClient` is still using it — keep `time` because `httpClient` uses `25 * time.Second`. + +The full import block for `merger.go` after this step: +```go +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" + + "github.com/pdfcpu/pdfcpu/pkg/api" +) +``` + +- [ ] **Step 5.2: Run all tests** + +```bash +cd services/ibl_merge_report_service && go test ./... -v +``` + +Expected: all previously passing tests still pass. No new tests in this task. + +- [ ] **Step 5.3: Build to verify no compile errors** + +```bash +cd services/ibl_merge_report_service && go build ./... +``` + +Expected: compiles cleanly. + +- [ ] **Step 5.4: Commit** + +```bash +cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab +git add services/ibl_merge_report_service/ +git commit -m "TASKCODE - implement fetchPDF and mergePDFs" +``` + +--- + +### Task 6: Deployment files + +**Files:** +- Create: `scripts/build-merge-service.sh` +- Create: `services/ibl_merge_report_service/ibl-merge-report.service` + +- [ ] **Step 6.1: Create build script** + +`scripts/build-merge-service.sh`: +```bash +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="$REPO_ROOT/services/ibl_merge_report_service" +OUT_DIR="$REPO_ROOT/up" + +mkdir -p "$OUT_DIR" + +echo "Building ibl_merge_report_service for linux/amd64..." +cd "$SRC_DIR" +GOOS=linux GOARCH=amd64 go build -o "$OUT_DIR/ibl-merge-report-service" . +echo "Binary written to: $OUT_DIR/ibl-merge-report-service" +``` + +Make executable: +```bash +chmod +x scripts/build-merge-service.sh +``` + +- [ ] **Step 6.2: Create systemd unit template** + +`services/ibl_merge_report_service/ibl-merge-report.service`: +```ini +[Unit] +Description=IBL Merge Report Service +After=network.target + +[Service] +Type=simple +User=one +WorkingDirectory=/home/one/project/ibl_merge_report_service +ExecStart=/home/one/project/ibl_merge_report_service/ibl-merge-report-service +Environment=MERGE_LISTEN_ADDR=127.0.0.1:8005 +Environment=MERGE_INTERNAL_SECRET=REPLACE_WITH_VALUE_FROM_DB +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +`MERGE_INTERNAL_SECRET` must match the value of `S_SystemsMergeReportServiceSecret` in `conf_systems`. + +Deployment steps on IBL server: +1. Build: `bash scripts/build-merge-service.sh` +2. Copy binary: `scp -i /Users/fajrihardhitamurti/id_rsa up/ibl-merge-report-service one@10.9.20.31:/home/one/project/ibl_merge_report_service/` +3. Copy unit file: `scp -i /Users/fajrihardhitamurti/id_rsa services/ibl_merge_report_service/ibl-merge-report.service one@10.9.20.31:/tmp/` +4. On server: `sudo mv /tmp/ibl-merge-report.service /etc/systemd/system/` +5. On server: edit `/etc/systemd/system/ibl-merge-report.service` to set the real `MERGE_INTERNAL_SECRET` +6. On server: `sudo systemctl daemon-reload && sudo systemctl enable ibl-merge-report && sudo systemctl start ibl-merge-report` +7. Verify: `sudo systemctl status ibl-merge-report` + +- [ ] **Step 6.3: Run build script to verify it compiles for Linux** + +```bash +cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab && bash scripts/build-merge-service.sh +``` + +Expected: `up/ibl-merge-report-service` is created (Linux ELF binary). + +- [ ] **Step 6.4: Commit** + +```bash +cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab +git add scripts/build-merge-service.sh services/ibl_merge_report_service/ibl-merge-report.service up/ibl-merge-report-service +git commit -m "TASKCODE - add build script and systemd unit for merge report service" +``` + +--- + +### Task 7: Run all tests and final verification + +- [ ] **Step 7.1: Run full test suite** + +```bash +cd services/ibl_merge_report_service && go test ./... -v +``` + +Expected: all tests pass. Final count: 6 `TestValidate*` + 8 `TestHandler*` + 4 `TestFetchPDF*` = 18 tests. + +- [ ] **Step 7.2: Run go vet** + +```bash +cd services/ibl_merge_report_service && go vet ./... +``` + +Expected: no output (no issues). + +- [ ] **Step 7.3: Smoke test locally (macOS)** + +```bash +cd services/ibl_merge_report_service && go run . & +sleep 1 +curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8005/merge \ + -H "X-Internal-Secret: ibl-merge-secret" \ + -H "Content-Type: application/json" \ + -d '{"name":"test.pdf","urls":["http://127.0.0.1:9999/nope"],"mergeRequestID":1,"T_OrderHeaderID":1}' +``` + +Expected: HTTP `502` (source unavailable — correct error code for unreachable source). + +Kill background process: `kill %1` + +- [ ] **Step 7.4: Verify secret validation** + +```bash +curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8005/merge \ + -H "X-Internal-Secret: wrong-secret" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +Expected: `401`. + +--- + +## Error Code Reference + +| Go returns | PHP maps to | Meaning | +|------------|------------|---------| +| `401` | `MERGE_SERVICE_UNAUTHORIZED` | Bad secret | +| `404` | `MERGE_SOURCE_NOT_FOUND` | Source PDF returned 404 | +| `422` | `MERGE_SERVICE_REJECTED` | Validation error | +| `500` | `MERGE_SERVICE_FAILED` | pdfcpu merge error | +| `502` | `MERGE_SERVICE_FAILED` | Source fetch network/non-404 error | + +--- + +## Self-Review: Spec Coverage + +- [x] Service listens on `127.0.0.1:8005` (configurable via `MERGE_LISTEN_ADDR`) +- [x] `POST /merge` endpoint +- [x] Validates `X-Internal-Secret` header → 401 +- [x] Validates JSON body fields (name, urls, mergeRequestID, T_OrderHeaderID) → 422 +- [x] Fetches each URL sequentially — if any fails, entire request fails +- [x] Merges with pdfcpu, streams as `application/pdf` +- [x] No cache / no file artifacts (in-memory only) +- [x] Error responses use HTTP status codes that map correctly to PHP gateway error codes +- [x] Build script for Linux amd64 +- [x] Systemd unit for IBL server deployment diff --git a/scripts/build-merge-service.sh b/scripts/build-merge-service.sh new file mode 100755 index 00000000..56def58c --- /dev/null +++ b/scripts/build-merge-service.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="$REPO_ROOT/services/ibl_merge_report_service" +OUT_DIR="$REPO_ROOT/build" + +mkdir -p "$OUT_DIR" + +echo "Building ibl_merge_report_service for linux/amd64..." +cd "$SRC_DIR" +GOOS=linux GOARCH=amd64 go build -o "$OUT_DIR/ibl-merge-report-service" . +echo "Binary written to: $OUT_DIR/ibl-merge-report-service" diff --git a/services/ibl_merge_report_service/go.mod b/services/ibl_merge_report_service/go.mod new file mode 100644 index 00000000..24ea6b39 --- /dev/null +++ b/services/ibl_merge_report_service/go.mod @@ -0,0 +1,18 @@ +module ibl-merge-report-service + +go 1.24.0 + +require github.com/pdfcpu/pdfcpu v0.11.1 + +require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.0 // indirect + github.com/hhrutter/tiff v1.0.2 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/image v0.32.0 // indirect + golang.org/x/text v0.30.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/services/ibl_merge_report_service/go.sum b/services/ibl_merge_report_service/go.sum new file mode 100644 index 00000000..ca71ded3 --- /dev/null +++ b/services/ibl_merge_report_service/go.sum @@ -0,0 +1,24 @@ +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= +github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= +github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= +github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/services/ibl_merge_report_service/handler.go b/services/ibl_merge_report_service/handler.go new file mode 100644 index 00000000..58fbc3d6 --- /dev/null +++ b/services/ibl_merge_report_service/handler.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" +) + +type mergeRequest struct { + Name string `json:"name"` + URLs []string `json:"urls"` + MergeRequestID int64 `json:"mergeRequestID"` + TOrderHeaderID int64 `json:"T_OrderHeaderID"` +} + +type mergeFunc func(urls []string) ([]byte, error) + +type mergeHandler struct { + secret string + merge mergeFunc +} + +func newMergeHandler(secret string, merge mergeFunc) http.Handler { + return &mergeHandler{secret: secret, merge: merge} +} + +func (h *mergeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + if r.Header.Get("X-Internal-Secret") != h.secret { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req mergeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := validateMergeRequest(&req); err != nil { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + pdfBytes, err := h.merge(req.URLs) + if err != nil { + var fe *fetchError + if errors.As(err, &fe) { + if fe.HTTPStatus == http.StatusNotFound { + writeError(w, http.StatusNotFound, "source PDF not found") + return + } + writeError(w, http.StatusBadGateway, "source PDF unavailable") + return + } + writeError(w, http.StatusInternalServerError, "merge failed") + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `inline; filename="`+req.Name+`"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(pdfBytes) +} + +func validateMergeRequest(req *mergeRequest) error { + if req.Name == "" { + return fmt.Errorf("name is required") + } + if len(req.URLs) == 0 { + return fmt.Errorf("urls must not be empty") + } + if req.MergeRequestID <= 0 { + return fmt.Errorf("mergeRequestID is required") + } + if req.TOrderHeaderID <= 0 { + return fmt.Errorf("T_OrderHeaderID is required") + } + return nil +} + +func writeError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/services/ibl_merge_report_service/handler_test.go b/services/ibl_merge_report_service/handler_test.go new file mode 100644 index 00000000..fd069c79 --- /dev/null +++ b/services/ibl_merge_report_service/handler_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestValidateMergeRequest_MissingName(t *testing.T) { + req := &mergeRequest{URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestValidateMergeRequest_EmptyURLs(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{}, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for empty urls") + } +} + +func TestValidateMergeRequest_NilURLs(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: nil, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for nil urls") + } +} + +func TestValidateMergeRequest_MissingMergeRequestID(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 0, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for missing mergeRequestID") + } +} + +func TestValidateMergeRequest_MissingTOrderHeaderID(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 0} + if err := validateMergeRequest(req); err == nil { + t.Fatal("expected error for missing T_OrderHeaderID") + } +} + +func TestValidateMergeRequest_Valid(t *testing.T) { + req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1} + if err := validateMergeRequest(req); err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestHandler_WrongMethod(t *testing.T) { + h := newMergeHandler("secret", nil) + req := httptest.NewRequest(http.MethodGet, "/merge", nil) + req.Header.Set("X-Internal-Secret", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} + +func TestHandler_WrongSecret(t *testing.T) { + h := newMergeHandler("correct", nil) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{}")) + req.Header.Set("X-Internal-Secret", "wrong") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestHandler_InvalidJSON(t *testing.T) { + h := newMergeHandler("s", nil) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{bad")) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandler_ValidationError(t *testing.T) { + h := newMergeHandler("s", nil) + body, _ := json.Marshal(mergeRequest{Name: "", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", w.Code) + } +} + +func TestHandler_FetchError404(t *testing.T) { + mockMerge := func(urls []string) ([]byte, error) { + return nil, &fetchError{URL: urls[0], HTTPStatus: http.StatusNotFound} + } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestHandler_FetchErrorNetwork(t *testing.T) { + mockMerge := func(urls []string) ([]byte, error) { + return nil, &fetchError{URL: urls[0], HTTPStatus: 0} + } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", w.Code) + } +} + +func TestHandler_MergeError(t *testing.T) { + mockMerge := func(urls []string) ([]byte, error) { + return nil, fmt.Errorf("pdfcpu: corrupt PDF") + } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +func TestHandler_Success(t *testing.T) { + fakeBytes := []byte("%PDF-1.4 fake-merged") + mockMerge := func(urls []string) ([]byte, error) { return fakeBytes, nil } + h := newMergeHandler("s", mockMerge) + body, _ := json.Marshal(mergeRequest{ + Name: "result.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body)) + req.Header.Set("X-Internal-Secret", "s") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/pdf" { + t.Fatalf("expected application/pdf, got %s", ct) + } + if !bytes.Equal(w.Body.Bytes(), fakeBytes) { + t.Fatal("response body does not match expected PDF bytes") + } +} diff --git a/services/ibl_merge_report_service/ibl-merge-report.service b/services/ibl_merge_report_service/ibl-merge-report.service new file mode 100644 index 00000000..37fc863f --- /dev/null +++ b/services/ibl_merge_report_service/ibl-merge-report.service @@ -0,0 +1,18 @@ +[Unit] +Description=IBL Merge Report Service +After=network.target + +[Service] +Type=simple +User=one +WorkingDirectory=/home/one/project/ibl_merge_report_service +ExecStart=/home/one/project/ibl_merge_report_service/ibl-merge-report-service +Environment=MERGE_LISTEN_ADDR=127.0.0.1:8005 +Environment=MERGE_INTERNAL_SECRET=REPLACE_WITH_VALUE_FROM_DB +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/services/ibl_merge_report_service/main.go b/services/ibl_merge_report_service/main.go new file mode 100644 index 00000000..f34d9d44 --- /dev/null +++ b/services/ibl_merge_report_service/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + secret := os.Getenv("MERGE_INTERNAL_SECRET") + if secret == "" { + secret = "ibl-merge-secret" + } + addr := os.Getenv("MERGE_LISTEN_ADDR") + if addr == "" { + addr = "127.0.0.1:8005" + } + + mux := http.NewServeMux() + mux.HandleFunc("/merge", func(w http.ResponseWriter, r *http.Request) { + newMergeHandler(secret, mergePDFs).ServeHTTP(w, r) + }) + + log.Printf("ibl_merge_report_service listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + fmt.Fprintf(os.Stderr, "fatal: %v\n", err) + os.Exit(1) + } +} diff --git a/services/ibl_merge_report_service/merger.go b/services/ibl_merge_report_service/merger.go new file mode 100644 index 00000000..bba86216 --- /dev/null +++ b/services/ibl_merge_report_service/merger.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" + + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +var httpClient = &http.Client{Timeout: 25 * time.Second} + +type fetchError struct { + URL string + HTTPStatus int // 0 = network/timeout +} + +func (e *fetchError) Error() string { + if e.HTTPStatus != 0 { + return fmt.Sprintf("source %s returned HTTP %d", e.URL, e.HTTPStatus) + } + return fmt.Sprintf("failed to fetch source %s", e.URL) +} + +func fetchPDF(url string) ([]byte, error) { + resp, err := httpClient.Get(url) + if err != nil { + return nil, &fetchError{URL: url, HTTPStatus: 0} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, &fetchError{URL: url, HTTPStatus: resp.StatusCode} + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &fetchError{URL: url, HTTPStatus: 0} + } + return data, nil +} + +func mergePDFs(urls []string) ([]byte, error) { + readers := make([]io.ReadSeeker, 0, len(urls)) + for _, url := range urls { + data, err := fetchPDF(url) + if err != nil { + return nil, err + } + readers = append(readers, bytes.NewReader(data)) + } + + var buf bytes.Buffer + if err := api.MergeRaw(readers, &buf, false, nil); err != nil { + return nil, fmt.Errorf("PDF merge error: %w", err) + } + return buf.Bytes(), nil +} diff --git a/services/ibl_merge_report_service/merger_test.go b/services/ibl_merge_report_service/merger_test.go new file mode 100644 index 00000000..af6a44ea --- /dev/null +++ b/services/ibl_merge_report_service/merger_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchPDF_Returns404Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := fetchPDF(srv.URL) + if err == nil { + t.Fatal("expected error for 404 response") + } + + var fe *fetchError + if !errors.As(err, &fe) { + t.Fatalf("expected *fetchError, got %T: %v", err, err) + } + if fe.HTTPStatus != http.StatusNotFound { + t.Fatalf("expected HTTPStatus 404, got %d", fe.HTTPStatus) + } +} + +func TestFetchPDF_Returns500Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := fetchPDF(srv.URL) + if err == nil { + t.Fatal("expected error for 500 response") + } + + var fe *fetchError + if !errors.As(err, &fe) { + t.Fatalf("expected *fetchError, got %T", err) + } + if fe.HTTPStatus != http.StatusInternalServerError { + t.Fatalf("expected HTTPStatus 500, got %d", fe.HTTPStatus) + } +} + +func TestFetchPDF_ReturnsBytes(t *testing.T) { + expected := []byte("%PDF-1.4 test content") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(expected) + })) + defer srv.Close() + + got, err := fetchPDF(srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != string(expected) { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestFetchPDF_NetworkError(t *testing.T) { + // Port that's almost certainly not listening + _, err := fetchPDF("http://127.0.0.1:19999/notexist") + if err == nil { + t.Fatal("expected error for unreachable host") + } + var fe *fetchError + if !errors.As(err, &fe) { + t.Fatalf("expected *fetchError, got %T: %v", err, err) + } + if fe.HTTPStatus != 0 { + t.Fatalf("expected HTTPStatus 0 for network error, got %d", fe.HTTPStatus) + } +}