Compare commits
12 Commits
7f2f26e596
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 055c8d8777 | |||
| 8e39fd4503 | |||
| 646575ff28 | |||
| 8bea266f03 | |||
| c6e2ce7fd5 | |||
| 099ec2150e | |||
| 9328556c28 | |||
| be97d94ef6 | |||
| 43f1b29aee | |||
| 5175d1dd65 | |||
| 6e0b718811 | |||
| c54332cd61 |
@@ -1,6 +1,9 @@
|
||||
ZARINHUB_ACCESS_TOKEN=
|
||||
ZARINHUB_API_USERNAME=
|
||||
ZARINHUB_API_PASSWORD=
|
||||
ZARINHUB_AUTH_ENDPOINT=https://zarin-hub.com/api/v5/Authentication/GetToken
|
||||
ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban
|
||||
ZARINHUB_TIMEOUT=5s
|
||||
ZARINHUB_RETRIES=2
|
||||
|
||||
DATABASE_PATH=./card-to-iban.db
|
||||
SERVER_ADDR=127.0.0.1:8080
|
||||
|
||||
125
README.md
125
README.md
@@ -1,70 +1,59 @@
|
||||
# Card to IBAN
|
||||
|
||||
A small full-stack application that converts an Iranian bank card number to its IBAN using the ZarinHub API. The backend is written in Go with the standard HTTP library, while the frontend uses Nuxt, Vue, and Tailwind CSS.
|
||||
A small full-stack application that converts an Iranian bank card number to its IBAN through ZarinHub.
|
||||
|
||||
The request flow is intentionally simple:
|
||||
- Backend: Go, `net/http`, SQLite
|
||||
- Frontend: Nuxt, Vue, Tailwind CSS
|
||||
- API documentation: OpenAPI 3.0 and Swagger UI
|
||||
|
||||
```text
|
||||
Nuxt UI -> HTTP handler -> Card service -> ZarinHub client
|
||||
|
|
||||
-> Audit repository -> SQLite
|
||||
```
|
||||
## Quick start with Docker
|
||||
|
||||
The HTTP handler coordinates the public API contract and audit persistence. The service validates card numbers, the ZarinHub client handles external requests, and the repository contains the SQLite-specific implementation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.27 or later
|
||||
- Node.js 22 or later
|
||||
- A valid ZarinHub access token
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the example environment file from the project root:
|
||||
Copy the environment template and add your ZarinHub API credentials:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.sample .env
|
||||
```
|
||||
|
||||
Set `ZARINHUB_ACCESS_TOKEN` in the new `.env` file. This file is ignored by Git.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ZARINHUB_ACCESS_TOKEN` | None; required | ZarinHub access token |
|
||||
| `ZARINHUB_ENDPOINT` | Official CardToIban endpoint | External service endpoint |
|
||||
| `ZARINHUB_TIMEOUT` | `5s` | External request timeout |
|
||||
| `DATABASE_PATH` | `./card-to-iban.db` | SQLite database file path |
|
||||
| `SERVER_ADDR` | `127.0.0.1:8080` | Backend HTTP address |
|
||||
| `FRONTEND_ORIGIN` | `http://127.0.0.1:3000` | Allowed frontend origin |
|
||||
|
||||
The frontend reads the following value from `frontend/.env` and uses the shown address by default:
|
||||
|
||||
```env
|
||||
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
|
||||
ZARINHUB_API_USERNAME=your-api-username
|
||||
ZARINHUB_API_PASSWORD=your-api-password
|
||||
```
|
||||
|
||||
## Running the Project
|
||||
Build and start the application:
|
||||
|
||||
Start the backend from the Go module directory. The root `.env` file is loaded automatically, and the database migration runs during startup:
|
||||
```powershell
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- App: `http://127.0.0.1:3000`
|
||||
- Health check: `http://127.0.0.1:8080/health`
|
||||
- Swagger UI: `http://127.0.0.1:8080/docs/`
|
||||
- OpenAPI document: `http://127.0.0.1:8080/openapi.json`
|
||||
|
||||
After backend changes, restart it or run `docker compose up --build` again so the running container uses the latest code.
|
||||
|
||||
## Local development
|
||||
|
||||
Start the backend:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
In another terminal, install the frontend dependencies and start the development server:
|
||||
Start the frontend in another terminal:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend is available at `http://127.0.0.1:3000`. The backend health endpoint is available at `http://127.0.0.1:8080/health`.
|
||||
The backend loads `.env` from the project root. The frontend uses `http://127.0.0.1:8080` by default; override it with `NUXT_PUBLIC_API_BASE_URL` when needed.
|
||||
|
||||
## API Contract
|
||||
|
||||
### Convert a card number to an IBAN
|
||||
## API
|
||||
|
||||
```http
|
||||
POST /api/v1/card-to-iban
|
||||
@@ -84,44 +73,17 @@ Successful response:
|
||||
}
|
||||
```
|
||||
|
||||
All errors use the same response structure. Error messages are returned in Persian to match the user interface:
|
||||
Errors use the same `requestId` returned in the `X-Request-ID` header. See Swagger UI for the complete request, response, and error contract.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_card",
|
||||
"message": "شماره کارت باید شامل ۱۶ رقم باشد."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
```
|
||||
## Implementation notes
|
||||
|
||||
The same request ID is also returned in the `X-Request-ID` response header.
|
||||
- The backend validates card numbers and refreshes ZarinHub access tokens automatically.
|
||||
- Every conversion attempt is audited in SQLite.
|
||||
- Card numbers and upstream responses are sanitized before storage; credentials and tokens are not persisted.
|
||||
- Unknown frontend routes display a branded 404 page with a link back to the application.
|
||||
- SQLite data is stored in the `card_to_iban_data` Docker volume when using Compose.
|
||||
|
||||
| HTTP status | Stable error code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `400` | `invalid_card` | The JSON body or card number format is invalid |
|
||||
| `405` | `method_not_allowed` | The request method is not `POST` |
|
||||
| `422` | `conversion_rejected` | ZarinHub rejected the conversion |
|
||||
| `502` | `upstream_failure` | ZarinHub authentication failed or its response was invalid |
|
||||
| `503` | `upstream_unavailable` | The upstream service is temporarily unavailable |
|
||||
| `504` | `upstream_timeout` | The upstream request timed out |
|
||||
| `500` | `internal_error` | Audit persistence or another internal operation failed |
|
||||
|
||||
## Auditing and Data Security
|
||||
|
||||
- Every conversion attempt creates one audit record, including invalid requests and upstream failures.
|
||||
- A response is returned only after its audit record is saved successfully. A database failure produces a generic `500` response.
|
||||
- Full card numbers are never written to the database or logs. Stored values are masked, for example `603799******5678`.
|
||||
- ZarinHub JSON responses are sanitized recursively before storage, and every field named `card` is masked.
|
||||
- Non-JSON upstream responses are not stored because their contents cannot be safely verified.
|
||||
- Client IP addresses are intentionally not collected, so the related audit column remains empty.
|
||||
- Structured logs contain only the request ID, route, HTTP status, audit outcome, and duration.
|
||||
- Request bodies are limited to 1 KiB, and ZarinHub response bodies are limited to 1 MiB.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests do not use real credentials. They use fakes and `httptest` for ZarinHub requests:
|
||||
## Tests
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
@@ -129,21 +91,8 @@ go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Run the frontend tests and production build with:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
The test suite covers card validation and masking, the conversion service, the ZarinHub client, response-size limits, the SQLite repository, JSON sanitization, HTTP outcomes, audit failures, Persian and Arabic digit normalization, and frontend success and error states.
|
||||
|
||||
## Assumptions and Limitations
|
||||
|
||||
- Backend validation intentionally accepts only exactly 16 ASCII digits. Luhn validation is outside the assessment requirements.
|
||||
- The frontend normalizes Persian and Arabic digits and removes spaces or hyphens before submission.
|
||||
- SQLite is sufficient for this assessment and uses a single writer connection for predictable behavior.
|
||||
- Only the IBAN is exposed to the frontend. Account details, owner information, and raw ZarinHub responses remain internal.
|
||||
- Retries, Docker, OpenAPI, CI, dark mode, and production infrastructure are intentionally outside the project scope.
|
||||
- The interface uses the existing ZarinPal design system's font and logo assets. The font license is stored in `frontend/public/fonts`.
|
||||
|
||||
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
bin
|
||||
coverage.out
|
||||
*.test
|
||||
36
backend/Dockerfile
Normal file
36
backend/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM golang:1.27.1-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
ARG GOPROXY=https://package-mirror.liara.ir/repository/go/
|
||||
ENV GOPROXY=$GOPROXY
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/api \
|
||||
./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S app \
|
||||
&& adduser -S -G app app \
|
||||
&& mkdir /data \
|
||||
&& chown app:app /data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /out/api ./api
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./api"]
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -11,8 +12,11 @@ type config struct {
|
||||
ServerAddr string
|
||||
FrontendOrigin string
|
||||
ZarinHubEndpoint string
|
||||
ZarinHubToken string
|
||||
ZarinHubAuthEndpoint string
|
||||
ZarinHubUsername string
|
||||
ZarinHubPassword string
|
||||
ZarinHubTimeout time.Duration
|
||||
ZarinHubRetries int
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
@@ -21,9 +25,14 @@ func loadConfig() (config, error) {
|
||||
}
|
||||
|
||||
func readConfig(getenv func(string) string) (config, error) {
|
||||
token := strings.TrimSpace(getenv("ZARINHUB_ACCESS_TOKEN"))
|
||||
if token == "" {
|
||||
return config{}, fmt.Errorf("ZARINHUB_ACCESS_TOKEN is required")
|
||||
username := strings.TrimSpace(getenv("ZARINHUB_API_USERNAME"))
|
||||
if username == "" {
|
||||
return config{}, fmt.Errorf("ZARINHUB_API_USERNAME is required")
|
||||
}
|
||||
|
||||
password := strings.TrimSpace(getenv("ZARINHUB_API_PASSWORD"))
|
||||
if password == "" {
|
||||
return config{}, fmt.Errorf("ZARINHUB_API_PASSWORD is required")
|
||||
}
|
||||
|
||||
timeoutText := valueOrDefault(
|
||||
@@ -37,6 +46,18 @@ func readConfig(getenv func(string) string) (config, error) {
|
||||
)
|
||||
}
|
||||
|
||||
retriesText := valueOrDefault(
|
||||
getenv("ZARINHUB_RETRIES"),
|
||||
"2",
|
||||
)
|
||||
|
||||
retries, err := strconv.Atoi(retriesText)
|
||||
if err != nil || retries < 0 {
|
||||
return config{}, fmt.Errorf(
|
||||
"ZARINHUB_RETRIES must be a non-negative integer",
|
||||
)
|
||||
}
|
||||
|
||||
return config{
|
||||
ServerAddr: valueOrDefault(
|
||||
getenv("SERVER_ADDR"),
|
||||
@@ -50,8 +71,14 @@ func readConfig(getenv func(string) string) (config, error) {
|
||||
getenv("ZARINHUB_ENDPOINT"),
|
||||
"https://zarin-hub.com/api/v5/KYC/CardToIban",
|
||||
),
|
||||
ZarinHubToken: token,
|
||||
ZarinHubAuthEndpoint: valueOrDefault(
|
||||
getenv("ZARINHUB_AUTH_ENDPOINT"),
|
||||
"https://zarin-hub.com/api/v5/Authentication/GetToken",
|
||||
),
|
||||
ZarinHubUsername: username,
|
||||
ZarinHubPassword: password,
|
||||
ZarinHubTimeout: timeout,
|
||||
ZarinHubRetries: retries,
|
||||
DatabasePath: valueOrDefault(
|
||||
getenv("DATABASE_PATH"),
|
||||
"./card-to-iban.db",
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
func TestReadConfigUsesDefaults(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"ZARINHUB_ACCESS_TOKEN": "test-token",
|
||||
"ZARINHUB_API_USERNAME": "test-username",
|
||||
"ZARINHUB_API_PASSWORD": "test-password",
|
||||
}
|
||||
|
||||
config, err := readConfig(func(key string) string {
|
||||
@@ -24,24 +25,44 @@ func TestReadConfigUsesDefaults(t *testing.T) {
|
||||
if config.FrontendOrigin != "http://127.0.0.1:3000" {
|
||||
t.Errorf("FrontendOrigin = %q", config.FrontendOrigin)
|
||||
}
|
||||
if config.ZarinHubAuthEndpoint != "https://zarin-hub.com/api/v5/Authentication/GetToken" {
|
||||
t.Errorf("ZarinHubAuthEndpoint = %q", config.ZarinHubAuthEndpoint)
|
||||
}
|
||||
if config.ZarinHubTimeout != 5*time.Second {
|
||||
t.Errorf("ZarinHubTimeout = %v", config.ZarinHubTimeout)
|
||||
}
|
||||
if config.ZarinHubRetries != 2 {
|
||||
t.Errorf("ZarinHubRetries = %d, want 2", config.ZarinHubRetries)
|
||||
}
|
||||
if config.DatabasePath != "./card-to-iban.db" {
|
||||
t.Errorf("DatabasePath = %q", config.DatabasePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigRejectsMissingAccessToken(t *testing.T) {
|
||||
func TestReadConfigRejectsMissingUsername(t *testing.T) {
|
||||
_, err := readConfig(func(key string) string { return "" })
|
||||
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_ACCESS_TOKEN") {
|
||||
t.Fatalf("readConfig() error = %v, want missing token error", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_USERNAME") {
|
||||
t.Fatalf("readConfig() error = %v, want missing username error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigRejectsMissingPassword(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"ZARINHUB_API_USERNAME": "test-username",
|
||||
}
|
||||
|
||||
_, err := readConfig(func(key string) string {
|
||||
return values[key]
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_PASSWORD") {
|
||||
t.Fatalf("readConfig() error = %v, want missing password error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigRejectsInvalidTimeout(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"ZARINHUB_ACCESS_TOKEN": "test-token",
|
||||
"ZARINHUB_API_USERNAME": "test-username",
|
||||
"ZARINHUB_API_PASSWORD": "test-password",
|
||||
"ZARINHUB_TIMEOUT": "later",
|
||||
}
|
||||
|
||||
@@ -52,3 +73,22 @@ func TestReadConfigRejectsInvalidTimeout(t *testing.T) {
|
||||
t.Fatalf("readConfig() error = %v, want timeout error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigRejectsInvalidRetries(t *testing.T) {
|
||||
testCases := []string{"-1", "invalid", "2.5"}
|
||||
|
||||
for _, tc := range testCases {
|
||||
values := map[string]string{
|
||||
"ZARINHUB_API_USERNAME": "test-username",
|
||||
"ZARINHUB_API_PASSWORD": "test-password",
|
||||
"ZARINHUB_RETRIES": tc,
|
||||
}
|
||||
|
||||
_, err := readConfig(func(key string) string {
|
||||
return values[key]
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_RETRIES") {
|
||||
t.Errorf("retries=%q: want error mentioning ZARINHUB_RETRIES, got %v", tc, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,29 @@ func run(logger *slog.Logger) error {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
|
||||
httpClient := zarinhub.NewHTTPClient(
|
||||
config.ZarinHubTimeout,
|
||||
config.ZarinHubRetries,
|
||||
logger,
|
||||
)
|
||||
tokenManager := zarinhub.NewTokenManager(
|
||||
httpClient,
|
||||
config.ZarinHubAuthEndpoint,
|
||||
config.ZarinHubUsername,
|
||||
config.ZarinHubPassword,
|
||||
)
|
||||
|
||||
authTimeout := time.Duration(config.ZarinHubRetries+1)*config.ZarinHubTimeout + 10*time.Second
|
||||
authContext, cancelAuth := context.WithTimeout(
|
||||
context.Background(),
|
||||
authTimeout,
|
||||
)
|
||||
_, err = tokenManager.AccessToken(authContext)
|
||||
cancelAuth()
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate with zarinhub: %w", err)
|
||||
}
|
||||
|
||||
db, err := storage.OpenSQLite(config.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -58,9 +81,11 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
|
||||
zarinHubClient := zarinhub.NewClient(
|
||||
&http.Client{Timeout: config.ZarinHubTimeout},
|
||||
httpClient,
|
||||
config.ZarinHubEndpoint,
|
||||
config.ZarinHubToken,
|
||||
tokenManager,
|
||||
config.ZarinHubRetries,
|
||||
logger,
|
||||
)
|
||||
cardService := service.NewCardService(zarinHubClient)
|
||||
auditRepository := repository.NewAuditRepository(db)
|
||||
@@ -73,6 +98,7 @@ func run(logger *slog.Logger) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", healthHandler)
|
||||
mux.HandleFunc("/api/v1/card-to-iban", handler.Convert)
|
||||
httpapi.RegisterDocumentation(mux)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: config.ServerAddr,
|
||||
|
||||
63
backend/internal/httpapi/client_ip.go
Normal file
63
backend/internal/httpapi/client_ip.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func extractClientIP(r *http.Request) *string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Priority headers commonly set by reverse proxies, load balancers, and CDNs.
|
||||
headers := []string{
|
||||
"CF-Connecting-IP", // Cloudflare
|
||||
"True-Client-IP", // Cloudflare Enterprise, Akamai
|
||||
"X-Real-IP", // Nginx, Traefik
|
||||
}
|
||||
|
||||
for _, header := range headers {
|
||||
if val := strings.TrimSpace(r.Header.Get(header)); val != "" {
|
||||
if ip := parseIP(val); ip != "" {
|
||||
return &ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// X-Forwarded-For can contain multiple comma-separated IPs: client, proxy1, proxy2. The original client IP is the first entry.
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip := parseIP(part); ip != "" {
|
||||
return &ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to RemoteAddr (typically "host:port" or "[ipv6]:port").
|
||||
if r.RemoteAddr != "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
if ip := parseIP(host); ip != "" {
|
||||
return &ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseIP(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "[")
|
||||
raw = strings.TrimSuffix(raw, "]")
|
||||
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ip.String()
|
||||
}
|
||||
143
backend/internal/httpapi/client_ip_test.go
Normal file
143
backend/internal/httpapi/client_ip_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request *http.Request
|
||||
headers map[string]string
|
||||
remoteAddr string
|
||||
wantIP *string
|
||||
}{
|
||||
{
|
||||
name: "nil request",
|
||||
request: nil,
|
||||
wantIP: nil,
|
||||
},
|
||||
{
|
||||
name: "CF-Connecting-IP takes precedence",
|
||||
headers: map[string]string{
|
||||
"CF-Connecting-IP": "203.0.113.10",
|
||||
"X-Real-IP": "198.51.100.20",
|
||||
"X-Forwarded-For": "192.0.2.30",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("203.0.113.10"),
|
||||
},
|
||||
{
|
||||
name: "True-Client-IP takes precedence over X-Real-IP",
|
||||
headers: map[string]string{
|
||||
"True-Client-IP": "203.0.113.11",
|
||||
"X-Real-IP": "198.51.100.20",
|
||||
"X-Forwarded-For": "192.0.2.30",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("203.0.113.11"),
|
||||
},
|
||||
{
|
||||
name: "X-Real-IP takes precedence over X-Forwarded-For",
|
||||
headers: map[string]string{
|
||||
"X-Real-IP": "198.51.100.20",
|
||||
"X-Forwarded-For": "192.0.2.30",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("198.51.100.20"),
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-For first entry is extracted",
|
||||
headers: map[string]string{
|
||||
"X-Forwarded-For": "192.0.2.30, 10.0.0.2, 172.16.0.5",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("192.0.2.30"),
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-For single entry",
|
||||
headers: map[string]string{
|
||||
"X-Forwarded-For": " 192.0.2.31 ",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("192.0.2.31"),
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-For skips invalid first entry to find valid IP",
|
||||
headers: map[string]string{
|
||||
"X-Forwarded-For": "unknown, 192.0.2.32",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("192.0.2.32"),
|
||||
},
|
||||
{
|
||||
name: "Fallback to RemoteAddr with port",
|
||||
headers: map[string]string{},
|
||||
remoteAddr: "192.168.1.100:12345",
|
||||
wantIP: ptr("192.168.1.100"),
|
||||
},
|
||||
{
|
||||
name: "Fallback to RemoteAddr without port",
|
||||
headers: map[string]string{},
|
||||
remoteAddr: "192.168.1.101",
|
||||
wantIP: ptr("192.168.1.101"),
|
||||
},
|
||||
{
|
||||
name: "IPv6 in RemoteAddr with port",
|
||||
headers: map[string]string{},
|
||||
remoteAddr: "[2001:db8::1]:8080",
|
||||
wantIP: ptr("2001:db8::1"),
|
||||
},
|
||||
{
|
||||
name: "IPv6 in header",
|
||||
headers: map[string]string{
|
||||
"X-Real-IP": "2001:db8::2",
|
||||
},
|
||||
remoteAddr: "10.0.0.1:54321",
|
||||
wantIP: ptr("2001:db8::2"),
|
||||
},
|
||||
{
|
||||
name: "Invalid headers fall back to RemoteAddr",
|
||||
headers: map[string]string{
|
||||
"X-Real-IP": "not-an-ip",
|
||||
"X-Forwarded-For": "also-not-an-ip",
|
||||
},
|
||||
remoteAddr: "192.168.1.102:443",
|
||||
wantIP: ptr("192.168.1.102"),
|
||||
},
|
||||
{
|
||||
name: "No headers and empty RemoteAddr",
|
||||
headers: map[string]string{},
|
||||
remoteAddr: "",
|
||||
wantIP: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.name != "nil request" {
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
}
|
||||
|
||||
got := extractClientIP(req)
|
||||
if (got == nil) != (tt.wantIP == nil) {
|
||||
t.Fatalf("extractClientIP() = %v, want %v", got, tt.wantIP)
|
||||
}
|
||||
if got != nil && tt.wantIP != nil && *got != *tt.wantIP {
|
||||
t.Errorf("extractClientIP() = %q, want %q", *got, *tt.wantIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
53
backend/internal/httpapi/docs.go
Normal file
53
backend/internal/httpapi/docs.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed openapi.json
|
||||
var openAPIDocument []byte
|
||||
|
||||
const swaggerUI = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Card to IBAN API documentation</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis]
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// RegisterDocumentation registers the OpenAPI document and its Swagger UI.
|
||||
func RegisterDocumentation(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /openapi.json", serveOpenAPI)
|
||||
mux.HandleFunc("GET /docs/", serveSwaggerUI)
|
||||
mux.HandleFunc("GET /docs", redirectToDocumentation)
|
||||
}
|
||||
|
||||
func serveOpenAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(openAPIDocument)
|
||||
}
|
||||
|
||||
func serveSwaggerUI(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(swaggerUI))
|
||||
}
|
||||
|
||||
func redirectToDocumentation(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/docs/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
124
backend/internal/httpapi/docs_test.go
Normal file
124
backend/internal/httpapi/docs_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterDocumentation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantContentType string
|
||||
wantBody string
|
||||
wantLocation string
|
||||
}{
|
||||
{
|
||||
name: "OpenAPI document",
|
||||
path: "/openapi.json",
|
||||
wantStatus: http.StatusOK,
|
||||
wantContentType: "application/json",
|
||||
wantBody: `"openapi": "3.0.3"`,
|
||||
},
|
||||
{
|
||||
name: "Swagger UI",
|
||||
path: "/docs/",
|
||||
wantStatus: http.StatusOK,
|
||||
wantContentType: "text/html; charset=utf-8",
|
||||
wantBody: `url: "/openapi.json"`,
|
||||
},
|
||||
{
|
||||
name: "documentation trailing slash",
|
||||
path: "/docs",
|
||||
wantStatus: http.StatusTemporaryRedirect,
|
||||
wantLocation: "/docs/",
|
||||
},
|
||||
{
|
||||
name: "removed Swagger route",
|
||||
path: "/swagger/",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterDocumentation(mux)
|
||||
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", response.Code, tt.wantStatus)
|
||||
}
|
||||
if tt.wantContentType != "" {
|
||||
if got := response.Header().Get("Content-Type"); got != tt.wantContentType {
|
||||
t.Errorf("Content-Type = %q, want %q", got, tt.wantContentType)
|
||||
}
|
||||
}
|
||||
if tt.wantBody != "" && !strings.Contains(response.Body.String(), tt.wantBody) {
|
||||
t.Errorf("body does not contain %q", tt.wantBody)
|
||||
}
|
||||
if got := response.Header().Get("Location"); got != tt.wantLocation {
|
||||
t.Errorf("Location = %q, want %q", got, tt.wantLocation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentCoversPublicAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterDocumentation(mux)
|
||||
request := httptest.NewRequest(http.MethodGet, "/openapi.json", nil)
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
|
||||
var document struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]map[string]struct {
|
||||
Responses map[string]json.RawMessage `json:"responses"`
|
||||
} `json:"paths"`
|
||||
Components struct {
|
||||
Schemas map[string]json.RawMessage `json:"schemas"`
|
||||
} `json:"components"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &document); err != nil {
|
||||
t.Fatalf("decode OpenAPI document: %v", err)
|
||||
}
|
||||
|
||||
if document.OpenAPI != "3.0.3" {
|
||||
t.Errorf("OpenAPI version = %q, want 3.0.3", document.OpenAPI)
|
||||
}
|
||||
if _, ok := document.Paths["/health"]["get"]; !ok {
|
||||
t.Error("OpenAPI document does not define GET /health")
|
||||
}
|
||||
|
||||
conversion, ok := document.Paths["/api/v1/card-to-iban"]["post"]
|
||||
if !ok {
|
||||
t.Fatal("OpenAPI document does not define POST /api/v1/card-to-iban")
|
||||
}
|
||||
for _, status := range []string{"200", "400", "405", "422", "500", "502", "503", "504"} {
|
||||
if _, ok := conversion.Responses[status]; !ok {
|
||||
t.Errorf("POST /api/v1/card-to-iban does not document response %s", status)
|
||||
}
|
||||
}
|
||||
for _, schema := range []string{
|
||||
"ConvertRequest",
|
||||
"ConvertResponse",
|
||||
"ErrorResponse",
|
||||
"HealthResponse",
|
||||
} {
|
||||
if _, ok := document.Components.Schemas[schema]; !ok {
|
||||
t.Errorf("OpenAPI document does not define schema %s", schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ func NewHandler(
|
||||
func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := h.now()
|
||||
requestID := h.newRequestID()
|
||||
clientIP := extractClientIP(r)
|
||||
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
@@ -89,6 +90,7 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
||||
RequestID: requestID,
|
||||
CreatedAt: startedAt,
|
||||
Duration: duration,
|
||||
ClientIP: clientIP,
|
||||
Status: outcome.auditStatus,
|
||||
MaskedCard: card.Mask(cardNumber),
|
||||
ResponseJSON: audit.SanitizeResponse(
|
||||
@@ -102,11 +104,17 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
||||
record.UpstreamHTTPStatus = &upstreamStatus
|
||||
}
|
||||
|
||||
var clientIPValue string
|
||||
if clientIP != nil {
|
||||
clientIPValue = *clientIP
|
||||
}
|
||||
|
||||
if err := h.audits.Save(r.Context(), record); err != nil {
|
||||
h.logger.ErrorContext(
|
||||
r.Context(),
|
||||
"request audit failed",
|
||||
"request_id", requestID,
|
||||
"client_ip", clientIPValue,
|
||||
"route", "/api/v1/card-to-iban",
|
||||
"http_status", http.StatusInternalServerError,
|
||||
"audit_status", audit.StatusInternalError,
|
||||
@@ -128,6 +136,7 @@ func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
|
||||
r.Context(),
|
||||
"request completed",
|
||||
"request_id", requestID,
|
||||
"client_ip", clientIPValue,
|
||||
"route", "/api/v1/card-to-iban",
|
||||
"http_status", outcome.httpStatus,
|
||||
"audit_status", outcome.auditStatus,
|
||||
|
||||
@@ -248,6 +248,10 @@ func TestHandlerConvert(t *testing.T) {
|
||||
t.Errorf("masked card = %q, want %q", record.MaskedCard, tt.wantMaskedCard)
|
||||
}
|
||||
|
||||
if record.ClientIP == nil || *record.ClientIP != "192.0.2.1" {
|
||||
t.Errorf("client IP = %v, want 192.0.2.1", record.ClientIP)
|
||||
}
|
||||
|
||||
if strings.Contains(string(record.ResponseJSON), "6037997512345678") {
|
||||
t.Error("audit response contains the full card number")
|
||||
}
|
||||
@@ -291,3 +295,75 @@ func TestHandlerFailsClosedWhenAuditSaveFails(t *testing.T) {
|
||||
t.Errorf("response body = %s, want internal_error", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerExtractsClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headerKey string
|
||||
headerVal string
|
||||
remoteAddr string
|
||||
wantIP string
|
||||
}{
|
||||
{
|
||||
name: "prefers CF-Connecting-IP",
|
||||
headerKey: "CF-Connecting-IP",
|
||||
headerVal: "203.0.113.88",
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
wantIP: "203.0.113.88",
|
||||
},
|
||||
{
|
||||
name: "prefers X-Forwarded-For over RemoteAddr",
|
||||
headerKey: "X-Forwarded-For",
|
||||
headerVal: "198.51.100.77, 10.0.0.2",
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
wantIP: "198.51.100.77",
|
||||
},
|
||||
{
|
||||
name: "uses RemoteAddr host without port",
|
||||
remoteAddr: "192.168.10.5:9999",
|
||||
wantIP: "192.168.10.5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
audits := &fakeAuditSaver{}
|
||||
handler := NewHandler(
|
||||
&fakeConverter{
|
||||
result: service.ConversionResult{
|
||||
IBAN: "IR820540102680020817909002",
|
||||
UpstreamHTTPStatus: http.StatusOK,
|
||||
},
|
||||
},
|
||||
audits,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/card-to-iban",
|
||||
strings.NewReader(`{"card":"6037997512345678"}`),
|
||||
)
|
||||
if tt.headerKey != "" {
|
||||
req.Header.Set(tt.headerKey, tt.headerVal)
|
||||
}
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.Convert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
if len(audits.records) != 1 {
|
||||
t.Fatalf("audit records count = %d, want 1", len(audits.records))
|
||||
}
|
||||
|
||||
recIP := audits.records[0].ClientIP
|
||||
if recIP == nil || *recIP != tt.wantIP {
|
||||
t.Errorf("ClientIP = %v, want %q", recIP, tt.wantIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
363
backend/internal/httpapi/openapi.json
Normal file
363
backend/internal/httpapi/openapi.json
Normal file
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Card to IBAN API",
|
||||
"version": "1.0.0",
|
||||
"description": "Converts an Iranian bank card number to its IBAN."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/",
|
||||
"description": "Current server"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "System"
|
||||
},
|
||||
{
|
||||
"name": "Conversion"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
"summary": "Check service health",
|
||||
"operationId": "getHealth",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The service is running.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HealthResponse"
|
||||
},
|
||||
"example": {
|
||||
"status": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/card-to-iban": {
|
||||
"post": {
|
||||
"tags": ["Conversion"],
|
||||
"summary": "Convert a card number to an IBAN",
|
||||
"operationId": "convertCardToIBAN",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConvertRequest"
|
||||
},
|
||||
"example": {
|
||||
"card": "6037997512345678"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The card number was converted successfully.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConvertResponse"
|
||||
},
|
||||
"example": {
|
||||
"data": {
|
||||
"iban": "IR820540102680020817909002"
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/InvalidCard"
|
||||
},
|
||||
"405": {
|
||||
"$ref": "#/components/responses/MethodNotAllowed"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/ConversionRejected"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#/components/responses/UpstreamFailure"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/UpstreamUnavailable"
|
||||
},
|
||||
"504": {
|
||||
"$ref": "#/components/responses/UpstreamTimeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"headers": {
|
||||
"RequestID": {
|
||||
"description": "Identifier generated for tracing this request.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"HealthResponse": {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["ok"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["card"],
|
||||
"properties": {
|
||||
"card": {
|
||||
"type": "string",
|
||||
"description": "Iranian bank card number containing exactly 16 ASCII digits.",
|
||||
"pattern": "^[0-9]{16}$",
|
||||
"minLength": 16,
|
||||
"maxLength": 16,
|
||||
"example": "6037997512345678"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertResponse": {
|
||||
"type": "object",
|
||||
"required": ["data", "requestId"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ConvertData"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"example": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConvertData": {
|
||||
"type": "object",
|
||||
"required": ["iban"],
|
||||
"properties": {
|
||||
"iban": {
|
||||
"type": "string",
|
||||
"pattern": "^IR[0-9]{24}$",
|
||||
"example": "IR820540102680020817909002"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"required": ["error", "requestId"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/ErrorDetail"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"example": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorDetail": {
|
||||
"type": "object",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_card",
|
||||
"method_not_allowed",
|
||||
"conversion_rejected",
|
||||
"internal_error",
|
||||
"upstream_failure",
|
||||
"upstream_unavailable",
|
||||
"upstream_timeout"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "A user-facing error message in Persian."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"InvalidCard": {
|
||||
"description": "The JSON body or card number is invalid.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "invalid_card",
|
||||
"message": "شماره کارت باید شامل ۱۶ رقم باشد."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MethodNotAllowed": {
|
||||
"description": "The request method is not supported for this path.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "method_not_allowed",
|
||||
"message": "این روش برای این مسیر پشتیبانی نمیشود."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConversionRejected": {
|
||||
"description": "The upstream service rejected the conversion.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "conversion_rejected",
|
||||
"message": "تبدیل این شماره کارت انجام نشد."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"InternalError": {
|
||||
"description": "An internal operation failed.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "internal_error",
|
||||
"message": "خطایی در پردازش درخواست رخ داد."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpstreamFailure": {
|
||||
"description": "Upstream authentication failed or its response was invalid.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "upstream_failure",
|
||||
"message": "ارتباط با سرویس بانکی با خطا روبهرو شد."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpstreamUnavailable": {
|
||||
"description": "The upstream service is temporarily unavailable.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "upstream_unavailable",
|
||||
"message": "سرویس بانکی موقتاً در دسترس نیست."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpstreamTimeout": {
|
||||
"description": "The upstream request timed out.",
|
||||
"headers": {
|
||||
"X-Request-ID": {
|
||||
"$ref": "#/components/headers/RequestID"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"error": {
|
||||
"code": "upstream_timeout",
|
||||
"message": "پاسخ سرویس بانکی بیش از حد طول کشید."
|
||||
},
|
||||
"requestId": "SERVER_GENERATED_REQUEST_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,37 +7,176 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"card-to-iban/internal/service"
|
||||
)
|
||||
|
||||
const maxResponseBodySize = 1 << 20
|
||||
const (
|
||||
maxResponseBodySize = 1 << 20
|
||||
|
||||
retryBaseDelay = 200 * time.Millisecond
|
||||
retryMaxDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
endpointURL string
|
||||
accessToken string
|
||||
tokenProvider TokenProvider
|
||||
maxRetries int
|
||||
retryWait func(ctx context.Context, delay time.Duration) bool
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ service.CardToIBANClient = (*Client)(nil)
|
||||
|
||||
type TokenProvider interface {
|
||||
AccessToken(ctx context.Context) (string, error)
|
||||
Invalidate(token string)
|
||||
}
|
||||
|
||||
func NewClient(
|
||||
httpClient *http.Client,
|
||||
endpointURL string,
|
||||
accessToken string,
|
||||
tokenProvider TokenProvider,
|
||||
maxRetries int,
|
||||
loggers ...*slog.Logger,
|
||||
) *Client {
|
||||
return &Client{
|
||||
if maxRetries < 0 {
|
||||
maxRetries = 0
|
||||
}
|
||||
|
||||
var logger *slog.Logger
|
||||
if len(loggers) > 0 && loggers[0] != nil {
|
||||
logger = loggers[0]
|
||||
}
|
||||
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
httpClient: httpClient,
|
||||
endpointURL: endpointURL,
|
||||
accessToken: accessToken,
|
||||
tokenProvider: tokenProvider,
|
||||
maxRetries: maxRetries,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if _, ok := httpClient.Transport.(*RetryTransport); !ok && maxRetries > 0 {
|
||||
rt := NewRetryTransport(
|
||||
httpClient.Transport,
|
||||
httpClient.Timeout,
|
||||
maxRetries,
|
||||
logger,
|
||||
)
|
||||
rt.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
if client.retryWait != nil {
|
||||
return client.retryWait(ctx, delay)
|
||||
}
|
||||
return waitForRetry(ctx, delay)
|
||||
}
|
||||
client.httpClient = &http.Client{
|
||||
Transport: rt,
|
||||
}
|
||||
} else if rt, ok := httpClient.Transport.(*RetryTransport); ok {
|
||||
origWait := rt.retryWait
|
||||
rt.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
if client.retryWait != nil {
|
||||
return client.retryWait(ctx, delay)
|
||||
}
|
||||
if origWait != nil {
|
||||
return origWait(ctx, delay)
|
||||
}
|
||||
return waitForRetry(ctx, delay)
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) log() *slog.Logger {
|
||||
if c.logger != nil {
|
||||
return c.logger
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func isRetryable(err error) bool {
|
||||
return errors.Is(err, ErrTimeout) ||
|
||||
errors.Is(err, ErrUnavailable)
|
||||
}
|
||||
|
||||
func retryDelay(attempt int) time.Duration {
|
||||
delay := retryBaseDelay
|
||||
|
||||
for i := 0; i < attempt; i++ {
|
||||
delay *= 2
|
||||
|
||||
if delay >= retryMaxDelay {
|
||||
return retryMaxDelay
|
||||
}
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
func waitForRetry(
|
||||
ctx context.Context,
|
||||
delay time.Duration,
|
||||
) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Convert(
|
||||
ctx context.Context,
|
||||
cardNumber string,
|
||||
) (service.ConversionResult, error) {
|
||||
return c.convertOnce(ctx, cardNumber)
|
||||
}
|
||||
|
||||
func (c *Client) convertOnce(
|
||||
ctx context.Context,
|
||||
cardNumber string,
|
||||
) (service.ConversionResult, error) {
|
||||
accessToken, err := c.tokenProvider.AccessToken(ctx)
|
||||
if err != nil {
|
||||
return service.ConversionResult{},
|
||||
fmt.Errorf("get zarinhub access token: %w", err)
|
||||
}
|
||||
|
||||
result, err := c.convert(ctx, cardNumber, accessToken)
|
||||
if !errors.Is(err, ErrAuthentication) ||
|
||||
result.UpstreamHTTPStatus != http.StatusUnauthorized {
|
||||
return result, err
|
||||
}
|
||||
|
||||
c.tokenProvider.Invalidate(accessToken)
|
||||
|
||||
accessToken, err = c.tokenProvider.AccessToken(ctx)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("refresh zarinhub access token: %w", err)
|
||||
}
|
||||
|
||||
return c.convert(ctx, cardNumber, accessToken)
|
||||
}
|
||||
|
||||
func (c *Client) convert(
|
||||
ctx context.Context,
|
||||
cardNumber string,
|
||||
accessToken string,
|
||||
) (service.ConversionResult, error) {
|
||||
payload, err := json.Marshal(convertRequest{
|
||||
Card: cardNumber,
|
||||
@@ -60,7 +199,7 @@ func (c *Client) Convert(
|
||||
|
||||
req.Header.Set(
|
||||
"Authorization",
|
||||
"Bearer "+c.accessToken,
|
||||
"Bearer "+accessToken,
|
||||
)
|
||||
|
||||
req.Header.Set(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package zarinhub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -97,7 +100,8 @@ func TestClientConvertSuccess(t *testing.T) {
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
token,
|
||||
&fakeTokenProvider{tokens: []string{token}},
|
||||
0,
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -171,7 +175,8 @@ func TestClientReturnsRejectedForApplicationFailure(
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
0,
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -263,7 +268,8 @@ func TestClientMapsHTTPStatusErrors(t *testing.T) {
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
0,
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -321,7 +327,8 @@ func TestClientReturnsInvalidResponseForMalformedJSON(
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
0,
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -366,7 +373,8 @@ func TestClientReturnsTimeout(t *testing.T) {
|
||||
Timeout: 50 * time.Millisecond,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
0,
|
||||
)
|
||||
|
||||
_, err := client.Convert(
|
||||
@@ -402,7 +410,8 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
0,
|
||||
)
|
||||
|
||||
_, err := client.Convert(
|
||||
@@ -414,3 +423,344 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
|
||||
t.Fatalf("error = %v, want ErrInvalidResponse", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRefreshesAndRetriesUnauthorizedRequest(t *testing.T) {
|
||||
const expectedIBAN = "IR820540102680020817909002"
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if requestCount == 1 {
|
||||
if authorization != "Bearer expired-token" {
|
||||
t.Errorf("first authorization = %q", authorization)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if authorization != "Bearer fresh-token" {
|
||||
t.Errorf("second authorization = %q", authorization)
|
||||
}
|
||||
fmt.Fprintf(w, `{
|
||||
"data":{"iban":%q},
|
||||
"meta":{"isSuccess":true,"errors":[]}
|
||||
}`, expectedIBAN)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokens := &fakeTokenProvider{tokens: []string{"expired-token", "fresh-token"}}
|
||||
client := NewClient(server.Client(), server.URL, tokens, 0)
|
||||
|
||||
result, err := client.Convert(context.Background(), "6037997512345678")
|
||||
if err != nil {
|
||||
t.Fatalf("Convert() error = %v", err)
|
||||
}
|
||||
if result.IBAN != expectedIBAN {
|
||||
t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN)
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Errorf("request count = %d, want 2", requestCount)
|
||||
}
|
||||
if len(tokens.invalidated) != 1 || tokens.invalidated[0] != "expired-token" {
|
||||
t.Errorf("invalidated tokens = %v", tokens.invalidated)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTokenProvider struct {
|
||||
tokens []string
|
||||
calls int
|
||||
invalidated []string
|
||||
}
|
||||
|
||||
func (p *fakeTokenProvider) AccessToken(context.Context) (string, error) {
|
||||
index := p.calls
|
||||
if index >= len(p.tokens) {
|
||||
index = len(p.tokens) - 1
|
||||
}
|
||||
p.calls++
|
||||
|
||||
return p.tokens[index], nil
|
||||
}
|
||||
|
||||
func (p *fakeTokenProvider) Invalidate(token string) {
|
||||
p.invalidated = append(p.invalidated, token)
|
||||
}
|
||||
|
||||
func TestRetryDelay(t *testing.T) {
|
||||
tests := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{attempt: -1, want: 200 * time.Millisecond},
|
||||
{attempt: 0, want: 200 * time.Millisecond},
|
||||
{attempt: 1, want: 400 * time.Millisecond},
|
||||
{attempt: 2, want: 800 * time.Millisecond},
|
||||
{attempt: 3, want: 1600 * time.Millisecond},
|
||||
{attempt: 4, want: 2 * time.Second},
|
||||
{attempt: 10, want: 2 * time.Second},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := retryDelay(tt.attempt)
|
||||
if got != tt.want {
|
||||
t.Errorf("retryDelay(%d) = %v, want %v", tt.attempt, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientSanitizesNegativeRetries(t *testing.T) {
|
||||
client := NewClient(nil, "", nil, -1)
|
||||
if client.maxRetries != 0 {
|
||||
t.Errorf("maxRetries = %d, want 0", client.maxRetries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetriesOnTransientErrorsUntilSuccess(t *testing.T) {
|
||||
const expectedIBAN = "IR820540102680020817909002"
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
if requestCount == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprint(w, `{"error":"temporary outage"}`)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"data":{"iban":%q},
|
||||
"meta":{"isSuccess":true,"errors":[]}
|
||||
}`, expectedIBAN)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var recordedDelays []time.Duration
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
2,
|
||||
)
|
||||
client.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
recordedDelays = append(recordedDelays, delay)
|
||||
return true
|
||||
}
|
||||
|
||||
result, err := client.Convert(context.Background(), "6037997512345678")
|
||||
if err != nil {
|
||||
t.Fatalf("Convert() unexpected error = %v", err)
|
||||
}
|
||||
if result.IBAN != expectedIBAN {
|
||||
t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN)
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Errorf("request count = %d, want 2", requestCount)
|
||||
}
|
||||
if len(recordedDelays) != 1 {
|
||||
t.Fatalf("len(recordedDelays) = %d, want 1", len(recordedDelays))
|
||||
}
|
||||
if recordedDelays[0] != 200*time.Millisecond {
|
||||
t.Errorf("delay[0] = %v, want 200ms", recordedDelays[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetriesExhausted(t *testing.T) {
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
fmt.Fprint(w, `{"error":"gateway timeout"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var recordedDelays []time.Duration
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
2,
|
||||
)
|
||||
client.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
recordedDelays = append(recordedDelays, delay)
|
||||
return true
|
||||
}
|
||||
|
||||
_, err := client.Convert(context.Background(), "6037997512345678")
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
if requestCount != 3 {
|
||||
t.Errorf("request count = %d, want 3", requestCount)
|
||||
}
|
||||
if len(recordedDelays) != 2 {
|
||||
t.Fatalf("len(recordedDelays) = %d, want 2", len(recordedDelays))
|
||||
}
|
||||
if recordedDelays[0] != 200*time.Millisecond {
|
||||
t.Errorf("delay[0] = %v, want 200ms", recordedDelays[0])
|
||||
}
|
||||
if recordedDelays[1] != 400*time.Millisecond {
|
||||
t.Errorf("delay[1] = %v, want 400ms", recordedDelays[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoesNotRetryNonTransientErrors(t *testing.T) {
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error":"invalid card format"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
2,
|
||||
)
|
||||
client.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
t.Error("retryWait should not be called for non-transient errors")
|
||||
return true
|
||||
}
|
||||
|
||||
_, err := client.Convert(context.Background(), "6037997512345678")
|
||||
if !errors.Is(err, ErrRejected) {
|
||||
t.Fatalf("error = %v, want ErrRejected", err)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Errorf("request count = %d, want 1", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStopsRetryOnContextCancellation(t *testing.T) {
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
2,
|
||||
)
|
||||
client.retryWait = func(c context.Context, delay time.Duration) bool {
|
||||
cancel()
|
||||
return waitForRetry(ctx, delay)
|
||||
}
|
||||
|
||||
_, err := client.Convert(ctx, "6037997512345678")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Errorf("request count = %d, want 1", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetryLogging(t *testing.T) {
|
||||
const expectedIBAN = "IR820540102680020817909002"
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
if requestCount == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprint(w, `{"error":"temporary outage"}`)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"data":{"iban":%q},
|
||||
"meta":{"isSuccess":true,"errors":[]}
|
||||
}`, expectedIBAN)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
2,
|
||||
logger,
|
||||
)
|
||||
client.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
result, err := client.Convert(context.Background(), "6037997512345678")
|
||||
if err != nil {
|
||||
t.Fatalf("Convert() unexpected error = %v", err)
|
||||
}
|
||||
if result.IBAN != expectedIBAN {
|
||||
t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN)
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
|
||||
if !strings.Contains(logs, "[INITIAL] zarinhub request") {
|
||||
t.Errorf("expected logs to contain '[INITIAL] zarinhub request', got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, `"is_retry":false`) {
|
||||
t.Errorf("expected logs to contain '\"is_retry\":false', got:\n%s", logs)
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "[RETRY] zarinhub request attempt 1") {
|
||||
t.Errorf("expected logs to contain '[RETRY] zarinhub request attempt 1', got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "[RETRY] zarinhub request failed, retrying in") {
|
||||
t.Errorf("expected logs to contain '[RETRY] zarinhub request failed, retrying in', got:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, `"is_retry":true`) {
|
||||
t.Errorf("expected logs to contain '\"is_retry\":true', got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetryLoggingExhausted(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprint(w, `{"error":"down"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
1,
|
||||
logger,
|
||||
)
|
||||
client.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
_, err := client.Convert(context.Background(), "6037997512345678")
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
if !strings.Contains(logs, "[RETRY] zarinhub request failed: all retries exhausted") {
|
||||
t.Errorf("expected logs to contain exhausted retries message, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
213
backend/internal/zarinhub/token_manager.go
Normal file
213
backend/internal/zarinhub/token_manager.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package zarinhub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenRefreshBefore = time.Minute
|
||||
|
||||
type tokenRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
Data tokenData `json:"data"`
|
||||
Meta responseMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type tokenData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpiresIn int `json:"expiresTn"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type tokenCall struct {
|
||||
done chan struct{}
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
httpClient *http.Client
|
||||
endpoint string
|
||||
username string
|
||||
password string
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
accessToken string
|
||||
refreshToken string
|
||||
expiresAt time.Time
|
||||
inFlight *tokenCall
|
||||
}
|
||||
|
||||
func NewTokenManager(
|
||||
httpClient *http.Client,
|
||||
endpoint string,
|
||||
username string,
|
||||
password string,
|
||||
) *TokenManager {
|
||||
return &TokenManager{
|
||||
httpClient: httpClient,
|
||||
endpoint: endpoint,
|
||||
username: username,
|
||||
password: password,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TokenManager) AccessToken(ctx context.Context) (string, error) {
|
||||
m.mu.Lock()
|
||||
|
||||
if m.tokenIsValid() {
|
||||
token := m.accessToken
|
||||
m.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
|
||||
if m.inFlight != nil {
|
||||
call := m.inFlight
|
||||
m.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-call.done:
|
||||
return call.token, call.err
|
||||
}
|
||||
}
|
||||
|
||||
call := &tokenCall{done: make(chan struct{})}
|
||||
m.inFlight = call
|
||||
refreshToken := m.refreshToken
|
||||
m.mu.Unlock()
|
||||
|
||||
data, err := m.fetchToken(ctx, refreshToken)
|
||||
if err != nil && refreshToken != "" && errors.Is(err, ErrAuthentication) {
|
||||
data, err = m.fetchToken(ctx, "")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if err == nil {
|
||||
m.accessToken = data.AccessToken
|
||||
m.refreshToken = data.RefreshToken
|
||||
m.expiresAt = m.now().Add(time.Duration(data.ExpiresIn) * time.Second)
|
||||
call.token = data.AccessToken
|
||||
}
|
||||
call.err = err
|
||||
close(call.done)
|
||||
m.inFlight = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
return call.token, call.err
|
||||
}
|
||||
|
||||
func (m *TokenManager) Invalidate(token string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if token == m.accessToken {
|
||||
m.expiresAt = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TokenManager) tokenIsValid() bool {
|
||||
return m.accessToken != "" &&
|
||||
m.now().Add(tokenRefreshBefore).Before(m.expiresAt)
|
||||
}
|
||||
|
||||
func (m *TokenManager) fetchToken(
|
||||
ctx context.Context,
|
||||
refreshToken string,
|
||||
) (tokenData, error) {
|
||||
request := tokenRequest{RefreshToken: refreshToken}
|
||||
if refreshToken == "" {
|
||||
request.Username = m.username
|
||||
request.Password = m.password
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return tokenData{}, fmt.Errorf("marshal zarinhub token request: %w", err)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
m.endpoint,
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return tokenData{}, fmt.Errorf("create zarinhub token request: %w", err)
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := m.httpClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
return tokenData{}, tokenTransportError(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBodySize+1))
|
||||
if err != nil {
|
||||
return tokenData{}, fmt.Errorf("%w: failed to read token response", ErrInvalidResponse)
|
||||
}
|
||||
if len(body) > maxResponseBodySize {
|
||||
return tokenData{}, fmt.Errorf("%w: token response body is too large", ErrInvalidResponse)
|
||||
}
|
||||
|
||||
switch {
|
||||
case response.StatusCode == http.StatusRequestTimeout:
|
||||
return tokenData{}, ErrTimeout
|
||||
case response.StatusCode == http.StatusTooManyRequests ||
|
||||
response.StatusCode >= http.StatusInternalServerError:
|
||||
return tokenData{}, ErrUnavailable
|
||||
case response.StatusCode < http.StatusOK ||
|
||||
response.StatusCode >= http.StatusMultipleChoices:
|
||||
return tokenData{}, ErrAuthentication
|
||||
}
|
||||
|
||||
var decoded tokenResponse
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
return tokenData{}, fmt.Errorf("%w: malformed token response", ErrInvalidResponse)
|
||||
}
|
||||
|
||||
if !decoded.Meta.IsSuccess {
|
||||
return tokenData{}, ErrAuthentication
|
||||
}
|
||||
if decoded.Data.AccessToken == "" ||
|
||||
decoded.Data.RefreshToken == "" ||
|
||||
decoded.Data.ExpiresIn <= 0 {
|
||||
return tokenData{}, fmt.Errorf("%w: incomplete token response", ErrInvalidResponse)
|
||||
}
|
||||
|
||||
return decoded.Data, nil
|
||||
}
|
||||
|
||||
func tokenTransportError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Errorf("%w: token request timed out", ErrTimeout)
|
||||
case errors.Is(err, context.Canceled):
|
||||
return fmt.Errorf("zarinhub token request canceled: %w", err)
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return fmt.Errorf("%w: token request timed out", ErrTimeout)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: token request failed", ErrUnavailable)
|
||||
}
|
||||
340
backend/internal/zarinhub/token_manager_test.go
Normal file
340
backend/internal/zarinhub/token_manager_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package zarinhub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenManagerAuthenticatesAndCachesToken(t *testing.T) {
|
||||
var calls int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
|
||||
var request tokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if request.Username != "test-username" || request.Password != "test-password" {
|
||||
t.Errorf("credentials = %q/%q", request.Username, request.Password)
|
||||
}
|
||||
if request.RefreshToken != "" {
|
||||
t.Errorf("refresh token = %q, want empty", request.RefreshToken)
|
||||
}
|
||||
|
||||
writeTokenResponse(t, w, "access-1", "refresh-1", 28800)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewTokenManager(
|
||||
server.Client(),
|
||||
server.URL,
|
||||
"test-username",
|
||||
"test-password",
|
||||
)
|
||||
|
||||
for range 2 {
|
||||
token, err := manager.AccessToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccessToken() error = %v", err)
|
||||
}
|
||||
if token != "access-1" {
|
||||
t.Errorf("token = %q, want access-1", token)
|
||||
}
|
||||
}
|
||||
|
||||
if calls != 1 {
|
||||
t.Errorf("authentication calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManagerRefreshesExpiringToken(t *testing.T) {
|
||||
var calls int
|
||||
currentTime := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
|
||||
var request tokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
|
||||
if calls == 1 {
|
||||
writeTokenResponse(t, w, "access-1", "refresh-1", 120)
|
||||
return
|
||||
}
|
||||
|
||||
if request.RefreshToken != "refresh-1" {
|
||||
t.Errorf("refresh token = %q, want refresh-1", request.RefreshToken)
|
||||
}
|
||||
if request.Username != "" || request.Password != "" {
|
||||
t.Error("refresh request included credentials")
|
||||
}
|
||||
writeTokenResponse(t, w, "access-2", "refresh-2", 28800)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
|
||||
manager.now = func() time.Time { return currentTime }
|
||||
|
||||
if _, err := manager.AccessToken(context.Background()); err != nil {
|
||||
t.Fatalf("initial AccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
currentTime = currentTime.Add(61 * time.Second)
|
||||
token, err := manager.AccessToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("refreshed AccessToken() error = %v", err)
|
||||
}
|
||||
if token != "access-2" {
|
||||
t.Errorf("token = %q, want access-2", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManagerFallsBackToCredentialsWhenRefreshIsRejected(t *testing.T) {
|
||||
var calls int
|
||||
currentTime := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
|
||||
var request tokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
|
||||
switch calls {
|
||||
case 1:
|
||||
writeTokenResponse(t, w, "access-1", "refresh-1", 61)
|
||||
case 2:
|
||||
if request.RefreshToken != "refresh-1" {
|
||||
t.Errorf("refresh token = %q, want refresh-1", request.RefreshToken)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case 3:
|
||||
if request.Username != "test-username" || request.Password != "test-password" {
|
||||
t.Error("credential fallback did not include API credentials")
|
||||
}
|
||||
writeTokenResponse(t, w, "access-2", "refresh-2", 28800)
|
||||
default:
|
||||
t.Errorf("unexpected authentication call %d", calls)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
|
||||
manager.now = func() time.Time { return currentTime }
|
||||
|
||||
if _, err := manager.AccessToken(context.Background()); err != nil {
|
||||
t.Fatalf("initial AccessToken() error = %v", err)
|
||||
}
|
||||
|
||||
currentTime = currentTime.Add(2 * time.Second)
|
||||
token, err := manager.AccessToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fallback AccessToken() error = %v", err)
|
||||
}
|
||||
if token != "access-2" {
|
||||
t.Errorf("token = %q, want access-2", token)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("authentication calls = %d, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManagerSharesConcurrentAuthentication(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
)
|
||||
release := make(chan struct{})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
<-release
|
||||
writeTokenResponse(t, w, "shared-access", "shared-refresh", 28800)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
|
||||
results := make(chan string, 5)
|
||||
errors := make(chan error, 5)
|
||||
|
||||
for range 5 {
|
||||
go func() {
|
||||
token, err := manager.AccessToken(context.Background())
|
||||
results <- token
|
||||
errors <- err
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(release)
|
||||
|
||||
for range 5 {
|
||||
if err := <-errors; err != nil {
|
||||
t.Errorf("AccessToken() error = %v", err)
|
||||
}
|
||||
if token := <-results; token != "shared-access" {
|
||||
t.Errorf("token = %q, want shared-access", token)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls != 1 {
|
||||
t.Errorf("authentication calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTokenResponse(
|
||||
t *testing.T,
|
||||
w http.ResponseWriter,
|
||||
accessToken string,
|
||||
refreshToken string,
|
||||
expiresIn int,
|
||||
) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"accessToken": accessToken,
|
||||
"tokenType": "Bearer",
|
||||
"expiresTn": expiresIn,
|
||||
"refreshToken": refreshToken,
|
||||
},
|
||||
"meta": map[string]any{
|
||||
"isSuccess": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManagerRetriesOnTransientErrorsUntilSuccess(t *testing.T) {
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeTokenResponse(t, w, "retried-access", "retried-refresh", 28800)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var recordedDelays []time.Duration
|
||||
transport := NewRetryTransport(
|
||||
server.Client().Transport,
|
||||
time.Second,
|
||||
2,
|
||||
)
|
||||
transport.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
recordedDelays = append(recordedDelays, delay)
|
||||
return true
|
||||
}
|
||||
httpClient := &http.Client{Transport: transport}
|
||||
|
||||
manager := NewTokenManager(httpClient, server.URL, "test-user", "test-pass")
|
||||
token, err := manager.AccessToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccessToken() error = %v", err)
|
||||
}
|
||||
if token != "retried-access" {
|
||||
t.Errorf("token = %q, want retried-access", token)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls)
|
||||
}
|
||||
if len(recordedDelays) != 1 {
|
||||
t.Fatalf("len(recordedDelays) = %d, want 1", len(recordedDelays))
|
||||
}
|
||||
if recordedDelays[0] != 200*time.Millisecond {
|
||||
t.Errorf("delay[0] = %v, want 200ms", recordedDelays[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManagerRetriesOnTimeoutUntilSuccess(t *testing.T) {
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
writeTokenResponse(t, w, "timeout-recovered-access", "timeout-recovered-refresh", 28800)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var recordedDelays []time.Duration
|
||||
transport := NewRetryTransport(
|
||||
server.Client().Transport,
|
||||
50*time.Millisecond,
|
||||
2,
|
||||
)
|
||||
transport.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
recordedDelays = append(recordedDelays, delay)
|
||||
return true
|
||||
}
|
||||
httpClient := &http.Client{Transport: transport}
|
||||
|
||||
manager := NewTokenManager(httpClient, server.URL, "test-user", "test-pass")
|
||||
token, err := manager.AccessToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccessToken() error = %v", err)
|
||||
}
|
||||
if token != "timeout-recovered-access" {
|
||||
t.Errorf("token = %q, want timeout-recovered-access", token)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls)
|
||||
}
|
||||
if len(recordedDelays) != 1 {
|
||||
t.Fatalf("len(recordedDelays) = %d, want 1", len(recordedDelays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManagerRetriesExhausted(t *testing.T) {
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var recordedDelays []time.Duration
|
||||
transport := NewRetryTransport(
|
||||
server.Client().Transport,
|
||||
time.Second,
|
||||
2,
|
||||
)
|
||||
transport.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
recordedDelays = append(recordedDelays, delay)
|
||||
return true
|
||||
}
|
||||
httpClient := &http.Client{Transport: transport}
|
||||
|
||||
manager := NewTokenManager(httpClient, server.URL, "test-user", "test-pass")
|
||||
_, err := manager.AccessToken(context.Background())
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrUnavailable", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("calls = %d, want 3", calls)
|
||||
}
|
||||
if len(recordedDelays) != 2 {
|
||||
t.Fatalf("len(recordedDelays) = %d, want 2", len(recordedDelays))
|
||||
}
|
||||
}
|
||||
239
backend/internal/zarinhub/transport.go
Normal file
239
backend/internal/zarinhub/transport.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package zarinhub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RetryTransport struct {
|
||||
base http.RoundTripper
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
retryWait func(ctx context.Context, delay time.Duration) bool
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewRetryTransport(
|
||||
base http.RoundTripper,
|
||||
timeout time.Duration,
|
||||
maxRetries int,
|
||||
loggers ...*slog.Logger,
|
||||
) *RetryTransport {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if maxRetries < 0 {
|
||||
maxRetries = 0
|
||||
}
|
||||
|
||||
var logger *slog.Logger
|
||||
if len(loggers) > 0 && loggers[0] != nil {
|
||||
logger = loggers[0]
|
||||
}
|
||||
|
||||
return &RetryTransport{
|
||||
base: base,
|
||||
timeout: timeout,
|
||||
maxRetries: maxRetries,
|
||||
retryWait: waitForRetry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func NewHTTPClient(
|
||||
timeout time.Duration,
|
||||
maxRetries int,
|
||||
loggers ...*slog.Logger,
|
||||
) *http.Client {
|
||||
transport := NewRetryTransport(
|
||||
http.DefaultTransport,
|
||||
timeout,
|
||||
maxRetries,
|
||||
loggers...,
|
||||
)
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RetryTransport) log() *slog.Logger {
|
||||
if t.logger != nil {
|
||||
return t.logger
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
waitFn := t.retryWait
|
||||
if waitFn == nil {
|
||||
waitFn = waitForRetry
|
||||
}
|
||||
|
||||
if req.Body != nil && req.GetBody == nil {
|
||||
bodyBytes, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = req.Body.Close()
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
resp *http.Response
|
||||
err error
|
||||
)
|
||||
|
||||
for attempt := 0; attempt <= t.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
t.log().InfoContext(
|
||||
req.Context(),
|
||||
fmt.Sprintf("[RETRY] zarinhub request attempt %d", attempt),
|
||||
"is_retry", true,
|
||||
"retry_count", attempt,
|
||||
)
|
||||
} else {
|
||||
t.log().InfoContext(
|
||||
req.Context(),
|
||||
"[INITIAL] zarinhub request",
|
||||
"is_retry", false,
|
||||
"retry_count", 0,
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
attemptCtx context.Context
|
||||
cancelAttempt context.CancelFunc
|
||||
)
|
||||
if t.timeout > 0 {
|
||||
attemptCtx, cancelAttempt = context.WithTimeout(req.Context(), t.timeout)
|
||||
} else {
|
||||
attemptCtx = req.Context()
|
||||
cancelAttempt = func() {}
|
||||
}
|
||||
|
||||
attemptReq := req.Clone(attemptCtx)
|
||||
if req.GetBody != nil {
|
||||
body, bodyErr := req.GetBody()
|
||||
if bodyErr != nil {
|
||||
cancelAttempt()
|
||||
return nil, bodyErr
|
||||
}
|
||||
attemptReq.Body = body
|
||||
}
|
||||
|
||||
resp, err = t.base.RoundTrip(attemptReq)
|
||||
|
||||
if req.Context().Err() != nil {
|
||||
cancelAttempt()
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if errors.Is(req.Context().Err(), context.Canceled) {
|
||||
return nil, fmt.Errorf("zarinhub request canceled: %w", req.Context().Err())
|
||||
}
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
|
||||
isErrRetryable := false
|
||||
var retryReason error
|
||||
|
||||
if err != nil {
|
||||
isErrRetryable = isTransportErrorRetryable(err)
|
||||
retryReason = err
|
||||
} else if isStatusCodeRetryable(resp.StatusCode) {
|
||||
isErrRetryable = true
|
||||
retryReason = fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if !isErrRetryable {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body = &bodyWithCancel{
|
||||
ReadCloser: resp.Body,
|
||||
cancel: cancelAttempt,
|
||||
}
|
||||
} else {
|
||||
cancelAttempt()
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if attempt == t.maxRetries {
|
||||
if attempt > 0 {
|
||||
t.log().ErrorContext(
|
||||
req.Context(),
|
||||
"[RETRY] zarinhub request failed: all retries exhausted",
|
||||
"is_retry", true,
|
||||
"retry_count", attempt,
|
||||
"error", retryReason,
|
||||
)
|
||||
}
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body = &bodyWithCancel{
|
||||
ReadCloser: resp.Body,
|
||||
cancel: cancelAttempt,
|
||||
}
|
||||
} else {
|
||||
cancelAttempt()
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if resp != nil && resp.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
cancelAttempt()
|
||||
|
||||
delay := retryDelay(attempt)
|
||||
t.log().WarnContext(
|
||||
req.Context(),
|
||||
fmt.Sprintf("[RETRY] zarinhub request failed, retrying in %v (retry %d)", delay, attempt+1),
|
||||
"is_retry", true,
|
||||
"next_retry", attempt+1,
|
||||
"delay_ms", delay.Milliseconds(),
|
||||
"error", retryReason,
|
||||
)
|
||||
|
||||
if !waitFn(req.Context(), delay) {
|
||||
if req.Context().Err() != nil {
|
||||
if errors.Is(req.Context().Err(), context.Canceled) {
|
||||
return nil, fmt.Errorf("zarinhub request canceled: %w", req.Context().Err())
|
||||
}
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
return nil, context.Canceled
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func isTransportErrorRetryable(err error) bool {
|
||||
return !errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
func isStatusCodeRetryable(statusCode int) bool {
|
||||
return statusCode == http.StatusRequestTimeout ||
|
||||
statusCode == http.StatusTooManyRequests ||
|
||||
statusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
type bodyWithCancel struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (b *bodyWithCancel) Close() error {
|
||||
err := b.ReadCloser.Close()
|
||||
b.cancel()
|
||||
return err
|
||||
}
|
||||
211
backend/internal/zarinhub/transport_test.go
Normal file
211
backend/internal/zarinhub/transport_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package zarinhub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRetryTransportPreservesAndRewindsBody(t *testing.T) {
|
||||
var attempts int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
att := atomic.AddInt32(&attempts, 1)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body error: %v", err)
|
||||
}
|
||||
if string(body) != `{"payload":"important"}` {
|
||||
t.Errorf("unexpected body on attempt %d: %q", att, string(body))
|
||||
}
|
||||
if att < 3 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := NewRetryTransport(server.Client().Transport, time.Second, 3)
|
||||
transport.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
server.URL,
|
||||
bytes.NewReader([]byte(`{"payload":"important"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest error: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("client.Do error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Errorf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransportDoesNotRetryClientErrors(t *testing.T) {
|
||||
tests := []int{
|
||||
http.StatusBadRequest,
|
||||
http.StatusUnauthorized,
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
http.StatusUnprocessableEntity,
|
||||
}
|
||||
|
||||
for _, statusCode := range tests {
|
||||
t.Run(fmt.Sprintf("status_%d", statusCode), func(t *testing.T) {
|
||||
var attempts int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := NewRetryTransport(server.Client().Transport, time.Second, 2)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("client.Do error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != statusCode {
|
||||
t.Errorf("statusCode = %d, want %d", resp.StatusCode, statusCode)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransportPerAttemptTimeout(t *testing.T) {
|
||||
var attempts int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
att := atomic.AddInt32(&attempts, 1)
|
||||
if att == 1 {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := NewRetryTransport(server.Client().Transport, 40*time.Millisecond, 2)
|
||||
transport.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
// Parent context has plenty of time (5s)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("client.Do error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Errorf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransportParentContextCancellation(t *testing.T) {
|
||||
var attempts int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
transport := NewRetryTransport(server.Client().Transport, time.Second, 3)
|
||||
transport.retryWait = func(c context.Context, delay time.Duration) bool {
|
||||
cancel() // cancel parent context during backoff
|
||||
return waitForRetry(ctx, delay)
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
_, err := client.Do(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTransportLoggingFormat(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
|
||||
transport := NewRetryTransport(server.Client().Transport, time.Second, 1, logger)
|
||||
transport.retryWait = func(ctx context.Context, delay time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
logStr := buf.String()
|
||||
if !strings.Contains(logStr, "[INITIAL] zarinhub request") {
|
||||
t.Errorf("expected logs to contain '[INITIAL] zarinhub request', got: %s", logStr)
|
||||
}
|
||||
if !strings.Contains(logStr, `"is_retry":false`) {
|
||||
t.Errorf("expected logs to contain '\"is_retry\":false', got: %s", logStr)
|
||||
}
|
||||
if !strings.Contains(logStr, "[RETRY] zarinhub request attempt 1") {
|
||||
t.Errorf("expected logs to contain '[RETRY] zarinhub request attempt 1', got: %s", logStr)
|
||||
}
|
||||
if !strings.Contains(logStr, "[RETRY] zarinhub request failed, retrying in") {
|
||||
t.Errorf("expected logs to contain retry warning, got: %s", logStr)
|
||||
}
|
||||
if !strings.Contains(logStr, "[RETRY] zarinhub request failed: all retries exhausted") {
|
||||
t.Errorf("expected logs to contain exhausted message, got: %s", logStr)
|
||||
}
|
||||
}
|
||||
36
compose.yaml
Normal file
36
compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: card-to-iban
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
SERVER_ADDR: 0.0.0.0:8080
|
||||
DATABASE_PATH: /data/card-to-iban.db
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- card_to_iban_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
environment:
|
||||
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:8080}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
card_to_iban_data:
|
||||
7
frontend/.dockerignore
Normal file
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.env.*
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
npm-debug.log*
|
||||
1
frontend/.env.sample
Normal file
1
frontend/.env.sample
Normal file
@@ -0,0 +1 @@
|
||||
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
|
||||
25
frontend/Dockerfile
Normal file
25
frontend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NITRO_HOST=0.0.0.0 \
|
||||
NITRO_PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build --chown=node:node /app/.output ./.output
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -1,37 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
const config = useRuntimeConfig()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh">
|
||||
<header class="border-border bg-surface h-14 border-b shadow-[0_1px_2px_rgba(0,0,0,.03)]">
|
||||
<div class="mx-auto flex h-full w-full max-w-[1120px] items-center px-4 sm:px-6 lg:px-8">
|
||||
<BrandLogo />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-[760px] px-4 py-10 sm:px-6 sm:py-14">
|
||||
<section aria-labelledby="page-title">
|
||||
<p class="text-brand mb-2 text-sm font-bold">سرویس تبدیل کارت به شبا</p>
|
||||
<h1 id="page-title" class="text-foreground text-2xl leading-[1.5] font-extrabold sm:text-3xl">
|
||||
شماره شبا را سریع و مطمئن پیدا کنید
|
||||
</h1>
|
||||
<p class="text-muted mt-3 max-w-[620px] text-sm leading-8 sm:text-[15px]">
|
||||
استعلام مستقیماً از سرویس زرینهاب انجام میشود. شماره کامل کارت در گزارشها یا پاسخ عمومی نگهداری نمیشود.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="mt-7 sm:mt-8">
|
||||
<CardConverter :api-base-url="config.public.apiBaseUrl" />
|
||||
</div>
|
||||
|
||||
<p class="text-muted mt-5 flex items-start gap-2 text-xs leading-6">
|
||||
<svg viewBox="0 0 24 24" class="mt-0.5 size-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
برای حفاظت از اطلاعات بانکی، فقط نسخه پوشیدهشده شماره کارت در سوابق فنی ثبت میشود.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CardConverter from './CardConverter.vue'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function getAlert() {
|
||||
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')
|
||||
|
||||
if (!alert) {
|
||||
throw new Error('Expected an alert to be rendered')
|
||||
}
|
||||
|
||||
return alert
|
||||
}
|
||||
|
||||
describe('CardConverter', () => {
|
||||
it('shows client validation without calling the API', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
@@ -19,7 +31,7 @@ describe('CardConverter', () => {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('۱۶ رقم')
|
||||
expect(getAlert().textContent).toContain('۱۶ رقم')
|
||||
})
|
||||
|
||||
it('renders the returned iban after a successful request', async () => {
|
||||
@@ -46,8 +58,6 @@ describe('CardConverter', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('IR82 0540 1026 8002 0817 9090 02')
|
||||
expect(document.activeElement?.getAttribute('data-result')).toBe('iban')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders the safe API error message', async () => {
|
||||
@@ -74,8 +84,6 @@ describe('CardConverter', () => {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
'تبدیل این شماره کارت انجام نشد.',
|
||||
)
|
||||
expect(getAlert().textContent).toContain('تبدیل این شماره کارت انجام نشد.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -252,7 +252,7 @@ onBeforeUnmount(dismissToast)
|
||||
v-if="iban"
|
||||
ref="resultElement"
|
||||
data-result="iban"
|
||||
class="border-border bg-accent/55 border-t p-5 outline-none sm:p-6"
|
||||
class="border-border bg-accent/55 border-t px-4 py-5 outline-none sm:p-6"
|
||||
tabindex="-1"
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -270,7 +270,7 @@ onBeforeUnmount(dismissToast)
|
||||
</div>
|
||||
|
||||
<output
|
||||
class="border-brand/15 bg-surface block overflow-x-auto rounded-lg border px-4 py-5 text-center font-mono text-lg font-bold tracking-[0.04em] whitespace-nowrap sm:text-xl"
|
||||
class="border-brand/15 bg-surface block overflow-x-auto rounded-lg border px-2.5 py-4 text-center font-mono text-xs font-bold tracking-normal whitespace-nowrap min-[390px]:text-sm sm:px-4 sm:py-5 sm:text-xl sm:tracking-[0.04em]"
|
||||
dir="ltr"
|
||||
>{{ formattedIBAN }}</output>
|
||||
</div>
|
||||
|
||||
21
frontend/app/error.test.ts
Normal file
21
frontend/app/error.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ErrorPage from './error.vue'
|
||||
|
||||
describe('ErrorPage', () => {
|
||||
it('renders a branded not-found page with a way home', () => {
|
||||
const wrapper = mount(ErrorPage, {
|
||||
props: {
|
||||
error: {
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('صفحه پیدا نشد')
|
||||
expect(wrapper.get('a').attributes('href')).toBe('/')
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toBe('زرینپال')
|
||||
})
|
||||
})
|
||||
55
frontend/app/error.vue
Normal file
55
frontend/app/error.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BrandLogo from './components/BrandLogo.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
error: NuxtError
|
||||
}>()
|
||||
|
||||
const isNotFound = computed(() => props.error.statusCode === 404)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh">
|
||||
<header class="border-border bg-surface h-14 border-b shadow-[0_1px_2px_rgba(0,0,0,.03)]">
|
||||
<div class="mx-auto flex h-full w-full max-w-[1120px] items-center px-4 sm:px-6 lg:px-8">
|
||||
<BrandLogo />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex w-full max-w-[760px] items-center px-4 py-12 sm:min-h-[calc(100dvh-3.5rem)] sm:px-6 sm:py-16">
|
||||
<section
|
||||
class="card border-border bg-surface w-full border p-6 sm:p-8"
|
||||
aria-labelledby="error-title"
|
||||
>
|
||||
<div class="bg-accent text-brand mb-5 grid size-12 place-items-center rounded-lg" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" class="size-6" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M12 8v5m0 3.5v.01" stroke-linecap="round" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-brand mb-2 text-sm font-bold">خطای {{ error.statusCode }}</p>
|
||||
<h1 id="error-title" class="text-foreground text-2xl leading-[1.5] font-extrabold sm:text-3xl">
|
||||
{{ isNotFound ? 'صفحه پیدا نشد' : 'خطایی رخ داده است' }}
|
||||
</h1>
|
||||
<p class="text-muted mt-3 max-w-[560px] text-sm leading-8 sm:text-[15px]">
|
||||
{{
|
||||
isNotFound
|
||||
? 'نشانی واردشده معتبر نیست یا صفحه موردنظر جابهجا شده است.'
|
||||
: 'در نمایش این صفحه مشکلی پیش آمد. لطفاً دوباره تلاش کنید.'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="bg-brand hover:bg-brand-hover text-on-brand mt-6 inline-flex min-h-11 items-center justify-center rounded-lg px-5 text-sm font-bold transition-colors"
|
||||
>
|
||||
بازگشت به صفحه اصلی
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
37
frontend/app/pages/index.vue
Normal file
37
frontend/app/pages/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const config = useRuntimeConfig()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh">
|
||||
<header class="border-border bg-surface h-14 border-b shadow-[0_1px_2px_rgba(0,0,0,.03)]">
|
||||
<div class="mx-auto flex h-full w-full max-w-[1120px] items-center px-4 sm:px-6 lg:px-8">
|
||||
<BrandLogo />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-[760px] px-4 py-10 sm:px-6 sm:py-14">
|
||||
<section aria-labelledby="page-title">
|
||||
<p class="text-brand mb-2 text-sm font-bold">سرویس تبدیل کارت به شبا</p>
|
||||
<h1 id="page-title" class="text-foreground text-2xl leading-[1.5] font-extrabold sm:text-3xl">
|
||||
شماره شبا را سریع و مطمئن پیدا کنید
|
||||
</h1>
|
||||
<p class="text-muted mt-3 max-w-[620px] text-sm leading-8 sm:text-[15px]">
|
||||
استعلام مستقیماً از سرویس زرینهاب انجام میشود. شماره کامل کارت در گزارشها یا پاسخ عمومی نگهداری نمیشود.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="mt-7 sm:mt-8">
|
||||
<CardConverter :api-base-url="config.public.apiBaseUrl" />
|
||||
</div>
|
||||
|
||||
<p class="text-muted mt-5 flex items-start gap-2 text-xs leading-6">
|
||||
<svg viewBox="0 0 24 24" class="mt-0.5 size-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
برای حفاظت از اطلاعات بانکی، فقط نسخه پوشیدهشده شماره کارت در سوابق فنی ثبت میشود.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user