feat: upload to external services clublog qrz
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
|
||||
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
|
||||
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
||||
|
||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||
// requires an api parameter that identifies the client software (not the
|
||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||
// rather than asking each user for one. It's an application identifier, not
|
||||
// a user secret, but note it is visible in the source and the binary.
|
||||
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
||||
|
||||
// UploadClublog pushes one ADIF record to Club Log in real time. The user
|
||||
// supplies the account email + password and the logbook callsign; the
|
||||
// application API key is embedded (clublogAppAPIKey), so users never need
|
||||
// one — same UX as Log4OM.
|
||||
//
|
||||
// Form params:
|
||||
//
|
||||
// email, password, callsign, adif, clientident, api
|
||||
//
|
||||
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
|
||||
// status with a plain-text reason in the body.
|
||||
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
switch {
|
||||
case email == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
|
||||
case strings.TrimSpace(adifRecord) == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", email)
|
||||
form.Set("password", cfg.Password)
|
||||
form.Set("callsign", call)
|
||||
form.Set("adif", adifRecord)
|
||||
form.Set("clientident", "OpsLog")
|
||||
// Club Log requires the application API key. Use OpsLog's embedded key;
|
||||
// a per-user override (cfg.APIKey) wins if one is ever configured.
|
||||
api := strings.TrimSpace(cfg.APIKey)
|
||||
if api == "" {
|
||||
api = clublogAppAPIKey
|
||||
}
|
||||
form.Set("api", api)
|
||||
|
||||
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// TestClublog validates the configured credentials by attempting a no-op
|
||||
// style check. Club Log has no dedicated status endpoint, so we report the
|
||||
// fields look complete; a real failure surfaces on the first upload.
|
||||
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
|
||||
_ = ctx
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Email) == "":
|
||||
return "", fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return "", fmt.Errorf("clublog: password not set")
|
||||
case strings.TrimSpace(cfg.Callsign) == "":
|
||||
return "", fmt.Errorf("clublog: logbook callsign not set")
|
||||
}
|
||||
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
|
||||
}
|
||||
|
||||
// clublogPost performs the form POST and maps the HTTP status to a result.
|
||||
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
msg := strings.TrimSpace(string(body))
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
return UploadResult{OK: true, Message: msg}, nil
|
||||
case isClublogDuplicate(resp.StatusCode, msg):
|
||||
// Club Log rejects an exact duplicate; treat as already-logged.
|
||||
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||
default:
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
|
||||
// so repeated uploads stay idempotent.
|
||||
func isClublogDuplicate(status int, msg string) bool {
|
||||
m := strings.ToLower(msg)
|
||||
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package extsvc uploads logged QSOs to external logbook services
|
||||
// (QRZ.com first; Clublog and LoTW to follow). Each service has its own
|
||||
// credentials and an upload mode chosen per-service: "immediate" pushes as
|
||||
// soon as the QSO is saved, "delayed" waits a random 1–2 minutes (like
|
||||
// Log4OM) so a mistakenly-logged QSO can still be edited or removed before
|
||||
// it leaves.
|
||||
//
|
||||
// The Manager is intentionally fire-and-forget: a failed or skipped upload
|
||||
// just leaves the QSO's per-service upload-status column empty, and the
|
||||
// (future) manual-upload window will let the user retry the backlog.
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errFromResult turns a non-OK result with no transport error into one
|
||||
// (defensive — uploaders normally return an error alongside !OK).
|
||||
func errFromResult(r UploadResult) error {
|
||||
if r.Message != "" {
|
||||
return errors.New(r.Message)
|
||||
}
|
||||
return errors.New("upload rejected")
|
||||
}
|
||||
|
||||
// Service identifies one external logbook.
|
||||
type Service string
|
||||
|
||||
const (
|
||||
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||
// ServiceLoTW to come.
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
type UploadMode string
|
||||
|
||||
const (
|
||||
// ModeImmediate uploads as soon as the QSO is logged.
|
||||
ModeImmediate UploadMode = "immediate"
|
||||
// ModeDelayed waits a random 1–2 minutes before uploading.
|
||||
ModeDelayed UploadMode = "delayed"
|
||||
)
|
||||
|
||||
// ServiceConfig is the per-service user configuration. It's a superset of
|
||||
// the credential shapes the different services need — each service reads
|
||||
// only the fields it uses:
|
||||
//
|
||||
// QRZ.com → APIKey, ForceStationCallsign
|
||||
// Club Log → Email, Password, Callsign, APIKey
|
||||
//
|
||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Password string `json:"password"` // Club Log account password
|
||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
}
|
||||
|
||||
// normalised returns the config with whitespace trimmed and a valid upload
|
||||
// mode (defaults to immediate).
|
||||
func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||
c.Email = strings.TrimSpace(c.Email)
|
||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||
if c.UploadMode != ModeDelayed {
|
||||
c.UploadMode = ModeImmediate
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ExternalServices bundles every service's config for the settings UI.
|
||||
// LoTW fields will be added as that service lands.
|
||||
type ExternalServices struct {
|
||||
QRZ ServiceConfig `json:"qrz"`
|
||||
Clublog ServiceConfig `json:"clublog"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
type UploadResult struct {
|
||||
OK bool // the service accepted (or already had) the QSO
|
||||
LogID string // service-assigned record id, when provided
|
||||
Message string // human-readable detail (reason on failure)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||
// keeps the upload-scheduling logic testable.
|
||||
type Deps struct {
|
||||
Client *http.Client
|
||||
|
||||
// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
|
||||
// overridden by forceCall when non-empty. ok=false means "skip silently"
|
||||
// (row gone, missing required fields, …).
|
||||
BuildADIF func(id int64, forceCall string) (record string, ok bool)
|
||||
|
||||
// MarkUploaded stamps the per-service upload status on the QSO row and
|
||||
// notifies the UI. Called once, on success.
|
||||
MarkUploaded func(svc Service, id int64, logID string)
|
||||
|
||||
// NotifyError surfaces a failed upload (logging + optional UI event).
|
||||
NotifyError func(svc Service, id int64, err error)
|
||||
|
||||
// Logf is an optional diagnostic logger.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Manager owns the external-service config snapshot and schedules uploads
|
||||
// when a QSO is logged. Immediate uploads run in their own goroutine;
|
||||
// delayed uploads use a timer with a random 1–2 minute fuse.
|
||||
type Manager struct {
|
||||
deps Deps
|
||||
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
}
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
if deps.Client == nil {
|
||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &Manager{
|
||||
deps: deps,
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) logf(format string, args ...any) {
|
||||
if m.deps.Logf != nil {
|
||||
m.deps.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfig replaces the active config snapshot (called after the user
|
||||
// saves the External Services settings).
|
||||
func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cfg.QRZ = cfg.QRZ.normalised()
|
||||
m.cfg = cfg
|
||||
}
|
||||
|
||||
// Config returns the current snapshot.
|
||||
func (m *Manager) Config() ExternalServices {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
}
|
||||
|
||||
// delaySeconds returns a random 60–120s fuse for delayed uploads.
|
||||
func (m *Manager) delaySeconds() time.Duration {
|
||||
m.mu.Lock()
|
||||
d := 60 + m.rnd.Intn(61) // [60, 120]
|
||||
m.mu.Unlock()
|
||||
return time.Duration(d) * time.Second
|
||||
}
|
||||
|
||||
// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
|
||||
// auto-log). It fans out to every enabled, auto-upload service in the
|
||||
// configured timing mode. Returns immediately.
|
||||
func (m *Manager) OnQSOLogged(id int64) {
|
||||
cfg := m.Config()
|
||||
|
||||
// QRZ.com
|
||||
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
|
||||
m.scheduleUpload(ServiceQRZ, id, qrz)
|
||||
}
|
||||
// Club Log — email + password + callsign are enough (no API key).
|
||||
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
|
||||
m.scheduleUpload(ServiceClublog, id, cl)
|
||||
}
|
||||
// LoTW will be added here.
|
||||
}
|
||||
|
||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeDelayed {
|
||||
d := m.delaySeconds()
|
||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
||||
return
|
||||
}
|
||||
go m.upload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// upload performs the actual push. It builds a fresh, lifecycle-independent
|
||||
// context so a delayed upload still completes even if it fires close to
|
||||
// shutdown.
|
||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var res UploadResult
|
||||
var err error
|
||||
switch svc {
|
||||
case ServiceQRZ:
|
||||
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return
|
||||
}
|
||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||
case ServiceClublog:
|
||||
// Club Log takes the logbook callsign as a separate param, so the
|
||||
// ADIF keeps the QSO's own station call (no override).
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return
|
||||
}
|
||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil || !res.OK {
|
||||
if err == nil {
|
||||
err = errFromResult(res)
|
||||
}
|
||||
m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// qrzAPIURL is the QRZ.com Logbook API endpoint. Note this is the LOGBOOK
|
||||
// API (key from the logbook's settings page), NOT the XML lookup
|
||||
// subscription used elsewhere for callsign data — they're different keys.
|
||||
const qrzAPIURL = "https://logbook.qrz.com/api"
|
||||
|
||||
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
|
||||
// apiKey. It returns OK when the QSO is inserted or already present
|
||||
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
|
||||
// treat as success so retries are idempotent).
|
||||
//
|
||||
// API shape (form-encoded POST):
|
||||
//
|
||||
// KEY=<logbook key>&ACTION=INSERT&ADIF=<one record>&OPTION=
|
||||
//
|
||||
// Response is URL-encoded key/values, e.g.:
|
||||
//
|
||||
// STATUS=OK&LOGID=123456&COUNT=1&...
|
||||
// STATUS=FAIL&REASON=Unable+to+add+QSO+...&...
|
||||
// STATUS=AUTH&REASON=invalid+api+key&...
|
||||
func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord string) (UploadResult, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return UploadResult{}, fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
if strings.TrimSpace(adifRecord) == "" {
|
||||
return UploadResult{}, fmt.Errorf("qrz: empty adif record")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "INSERT")
|
||||
// OPTION=REPLACE would overwrite an existing matching QSO; we leave it
|
||||
// empty so QRZ rejects duplicates (which we map to OK below).
|
||||
form.Set("ADIF", adifRecord)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("qrz: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("qrz: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("qrz: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return parseQRZResponse(string(body))
|
||||
}
|
||||
|
||||
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
|
||||
// human-readable summary (callsign + QSO count) for the settings UI. An
|
||||
// invalid key comes back as STATUS=AUTH → returned as an error.
|
||||
func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "STATUS")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: bad response: %w", err)
|
||||
}
|
||||
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
||||
if status == "AUTH" || status == "FAIL" {
|
||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||
if reason == "" {
|
||||
reason = "invalid API key"
|
||||
}
|
||||
return "", fmt.Errorf("qrz: %s", reason)
|
||||
}
|
||||
call := strings.TrimSpace(vals.Get("CALLSIGN"))
|
||||
count := strings.TrimSpace(vals.Get("COUNT"))
|
||||
switch {
|
||||
case call != "" && count != "":
|
||||
return fmt.Sprintf("Connected — %s logbook, %s QSOs", call, count), nil
|
||||
case call != "":
|
||||
return fmt.Sprintf("Connected — %s logbook", call), nil
|
||||
default:
|
||||
return "Connected — key OK", nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseQRZResponse decodes QRZ's "&"-joined, URL-encoded reply.
|
||||
func parseQRZResponse(body string) (UploadResult, error) {
|
||||
vals, err := url.ParseQuery(strings.TrimSpace(body))
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
|
||||
}
|
||||
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||
logID := strings.TrimSpace(vals.Get("LOGID"))
|
||||
|
||||
switch status {
|
||||
case "OK":
|
||||
return UploadResult{OK: true, LogID: logID, Message: reason}, nil
|
||||
case "FAIL":
|
||||
// A duplicate is a benign failure — the QSO is in the logbook, so
|
||||
// from our side the upload "succeeded". Detect it from the reason.
|
||||
if isDuplicateReason(reason) {
|
||||
return UploadResult{OK: true, LogID: logID, Message: "already in logbook"}, nil
|
||||
}
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: upload failed: %s", reason)
|
||||
case "AUTH":
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: auth error: %s", reason)
|
||||
default:
|
||||
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: unexpected status %q (%s)", status, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
|
||||
// already present.
|
||||
func isDuplicateReason(reason string) bool {
|
||||
r := strings.ToLower(reason)
|
||||
return strings.Contains(r, "duplicate") ||
|
||||
strings.Contains(r, "already") ||
|
||||
strings.Contains(r, "unable to add") && strings.Contains(r, "exists")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseQRZResponse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantOK bool
|
||||
wantErr bool
|
||||
logID string
|
||||
}{
|
||||
{"insert ok", "STATUS=OK&LOGID=123456&COUNT=1", true, false, "123456"},
|
||||
{"duplicate is ok", "STATUS=FAIL&REASON=Unable+to+add+QSO+duplicate", true, false, ""},
|
||||
{"already present", "STATUS=FAIL&REASON=QSO+already+in+logbook", true, false, ""},
|
||||
{"real failure", "STATUS=FAIL&REASON=Bad+ADIF", false, true, ""},
|
||||
{"auth failure", "STATUS=AUTH&REASON=invalid+api+key", false, true, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
res, err := parseQRZResponse(c.body)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
|
||||
}
|
||||
if res.OK != c.wantOK {
|
||||
t.Errorf("OK = %v, want %v", res.OK, c.wantOK)
|
||||
}
|
||||
if c.logID != "" && res.LogID != c.logID {
|
||||
t.Errorf("LogID = %q, want %q", res.LogID, c.logID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user