package api
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// PushOutcomes is an OPTIONAL upgrade on NotifyPlane: how one triggered
// push went, read back from notify. Without it a test can only say
// "sent", which is what a push service that refused every device also
// looks like from here.
type PushOutcomes interface {
PushOutcome(ctx context.Context, subscriber, txn string) (PushOutcome, error)
}
// PushOutcome is notify's record of one push: its status word and the
// error text that names each device that refused. Found is false until
// the push step has run.
type PushOutcome struct {
Found bool
Status string
Error string
// Devices is notify's per-device record, when it kept one.
Devices []PushDeviceOutcome
}
// PushDeviceOutcome is one device's outcome on a test push: the push
// service's host (never the endpoint), delivered, failed, retired or
// gone, and the status and reason when it refused.
type PushDeviceOutcome struct {
// EndpointSHA256 is notify's hash of the device's endpoint, which is
// also quarry's row key; Device is that row's user-agent label.
EndpointSHA256 string `json:"endpointSha256,omitempty"`
Device string `json:"device,omitempty"`
Host string `json:"host"`
Outcome string `json:"outcome"`
Status int `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
}
// pushSummary is a push outcome in the words the test control says.
type pushSummary struct {
// Outcome is delivered, partial, failed, or unknown (no answer yet).
Outcome string
// Delivered of Of devices took it; Of is 0 when notify did not say.
Delivered, Of int
// Why lists each refusal once, with a count: "2× 403 BadJwtToken".
Why []string
}
var (
deliveredRe = regexp.MustCompile(`delivered (\d+)/(\d+)`)
statusRe = regexp.MustCompile(`status (\d{3}): *(\{[^}]*\}|[^;)]*)`)
)
// summarizePush reads notify's status and error text. The text is
// notify's own sentence ("delivered 1/3: 2 permanent failure(s) (...)"),
// so this reads the parts that are stable — the delivered count, each
// HTTP status and its reason, timeouts — and keeps no URL: a push
// endpoint is a bearer secret for that device.
func summarizePush(o PushOutcome, devices int) pushSummary {
if !o.Found {
return pushSummary{Outcome: "unknown"}
}
s := pushSummary{}
if m := deliveredRe.FindStringSubmatch(o.Error); m != nil {
s.Delivered, _ = strconv.Atoi(m[1])
s.Of, _ = strconv.Atoi(m[2])
}
counts := map[string]int{}
var order []string
note := func(why string) {
if counts[why] == 0 {
order = append(order, why)
}
counts[why]++
}
for _, m := range statusRe.FindAllStringSubmatch(o.Error, -1) {
reason := strings.TrimSpace(m[2])
var body struct {
Reason string `json:"reason"`
}
if json.Unmarshal([]byte(reason), &body) == nil && body.Reason != "" {
reason = body.Reason
}
if len(reason) > 60 {
reason = reason[:60]
}
note(strings.TrimSpace(m[1] + " " + reason))
}
for i := 0; i < strings.Count(o.Error, "deadline exceeded"); i++ {
note("timed out")
}
for _, w := range order {
if counts[w] > 1 {
s.Why = append(s.Why, fmt.Sprintf("%d× %s", counts[w], w))
} else {
s.Why = append(s.Why, w)
}
}
switch {
case o.Status == "error" || (s.Of > 0 && s.Delivered == 0):
s.Outcome = "failed"
case s.Of > 0 && s.Delivered < s.Of:
s.Outcome = "partial"
case o.Status == "sent":
s.Outcome = "delivered"
if s.Of == 0 {
s.Delivered, s.Of = devices, devices
}
default:
s.Outcome = "unknown"
}
return s
}
// awaitPush polls notify for one push's outcome, briefly: the push step
// runs asynchronously after the trigger, usually within a second.
func awaitPush(ctx context.Context, p PushOutcomes, who, txn string, wait time.Duration) PushOutcome {
deadline := time.Now().Add(wait)
for {
o, err := p.PushOutcome(ctx, who, txn)
if err == nil && o.Found {
return o
}
if time.Now().After(deadline) || ctx.Err() != nil {
return PushOutcome{}
}
time.Sleep(400 * time.Millisecond)
}
}