FHM29052601IBL - implement ibl_merge_report_service Go service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sas.fajri
2026-05-29 15:39:51 +07:00
parent f2f1aed4b2
commit fd9511171b
11 changed files with 1380 additions and 0 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.DS_Store
# Added by code-review-graph
.code-review-graph/
build/
services/ibl_merge_report_service/ibl-merge-report-service

View File

@@ -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

14
scripts/build-merge-service.sh Executable file
View File

@@ -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"

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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})
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}