iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备

- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource
- 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI
- APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式
- Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎)
- 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换)
- 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册
- I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键
- ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
This commit is contained in:
2026-06-27 00:27:55 +08:00
parent 10cf36ecee
commit 44096069a7
59 changed files with 2585 additions and 144 deletions
+293
View File
@@ -0,0 +1,293 @@
// Package apns delivers Apple Push Notification service (APNs) notifications to
// iOS devices whose SSE connection is closed. It mirrors the shape of the push
// (Web Push) package: an inert Sender when unconfigured, per-subscription
// localization via the shared push.Localize machinery, and automatic pruning of
// dead device tokens on HTTP 410 (Unregistered) responses.
//
// APNs uses provider-token authentication: a cached ES256 JWT signed with the
// team's .p8 key, refreshed every ~50 minutes (Apple requires 20-60 minutes).
// HTTP/2 is negotiated automatically by Go's net/http Transport over TLS.
package apns
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// apnsTimeout bounds a single Notify call (including all subscriptions for one
// device), so a hung APNs endpoint cannot leak goroutines.
const apnsTimeout = 15 * time.Second
// jwtRefreshAge is how long we cache a provider JWT before minting a fresh one.
// Apple requires a new token between 20 and 60 minutes; 50 minutes gives a safe margin.
const jwtRefreshAge = 50 * time.Minute
// prodBase and sandboxBase are the APNs HTTP/2 endpoints.
const (
prodBase = "https://api.push.apple.com"
sandboxBase = "https://api.sandbox.push.apple.com"
)
// apsPayload is the JSON body for an APNs alert notification.
type apsPayload struct {
APS apsEnvelope `json:"aps"`
Type string `json:"type"`
URL string `json:"url"`
}
type apsEnvelope struct {
Alert apsAlert `json:"alert"`
Sound string `json:"sound"`
}
type apsAlert struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Sender delivers APNs notifications and prunes dead device tokens. A Sender
// built without all required config (key/keyID/teamID/topic) is inert: Enabled
// reports false and Notify is a no-op. Safe for concurrent use.
type Sender struct {
queries *db.Queries
key *ecdsa.PrivateKey
keyID string
teamID string
topic string
base string // APNs base URL; settable for tests
client *http.Client
mu sync.Mutex
cachedJWT string
jwtMintedAt time.Time
}
// NewSender returns a configured APNs Sender. keyPEM is the .p8 PKCS8 EC private
// key Apple issues. env is "prod" (default) or "sandbox". When any of keyPEM,
// keyID, teamID, or topic is empty/nil the returned Sender is inert (Enabled
// returns false, Notify is a no-op) so callers can invoke it unconditionally.
// A PEM parse failure is logged and also yields an inert Sender rather than a
// hard error, so startup can proceed with APNs disabled.
func NewSender(queries *db.Queries, keyPEM []byte, keyID, teamID, topic, env string) *Sender {
if len(keyPEM) == 0 || keyID == "" || teamID == "" || topic == "" {
return &Sender{}
}
block, _ := pem.Decode(keyPEM)
if block == nil {
slog.Error("apns: cannot decode PEM block from .p8 key; APNs disabled")
return &Sender{}
}
raw, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
slog.Error("apns: cannot parse PKCS8 private key; APNs disabled", "err", err)
return &Sender{}
}
ecKey, ok := raw.(*ecdsa.PrivateKey)
if !ok {
slog.Error("apns: .p8 key is not an ECDSA private key; APNs disabled")
return &Sender{}
}
base := prodBase
switch strings.ToLower(strings.TrimSpace(env)) {
case "sandbox":
base = sandboxBase
case "", "prod", "production":
base = prodBase
default:
// Unknown env is an operator typo; default to prod but log loudly so the
// sandbox/prod mismatch footgun (dev tokens → prod endpoint → BadDeviceToken)
// surfaces instead of silently selecting the wrong endpoint.
slog.Warn("apns: unknown CDROP_APNS_ENV, defaulting to production", "env", env)
}
return &Sender{
queries: queries,
key: ecKey,
keyID: keyID,
teamID: teamID,
topic: topic,
base: base,
client: &http.Client{Timeout: apnsTimeout},
}
}
// Enabled reports whether APNs is configured.
func (s *Sender) Enabled() bool { return s != nil && s.key != nil }
// Notify delivers n to every APNs subscription of (userID, deviceName).
// Best-effort and safe to call in a goroutine: failures are logged, a token the
// APNs service marks Unregistered (410) is pruned, and a no-op results when
// disabled or the device has no APNs subscriptions.
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n push.Notification) {
if !s.Enabled() {
return
}
ctx, cancel := context.WithTimeout(ctx, apnsTimeout)
defer cancel()
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(ctx, db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: userID,
DeviceName: deviceName,
})
if err != nil {
slog.Error("apns: list subscriptions failed",
"user", userID, "device", deviceName, "err", err)
return
}
if len(subs) == 0 {
return
}
jwtStr, err := s.providerJWT()
if err != nil {
slog.Error("apns: jwt signing failed", "err", err)
return
}
for _, sub := range subs {
title, body := push.Localize(n.Type, n.Params, sub.Locale)
url := n.URL
if url == "" {
url = "/"
}
payload := apsPayload{
APS: apsEnvelope{
Alert: apsAlert{Title: title, Body: body},
Sound: "default",
},
Type: n.Type,
URL: url,
}
s.send(ctx, sub.ID, sub.Endpoint, jwtStr, payload)
}
}
func (s *Sender) send(
ctx context.Context,
subID, deviceToken, jwtStr string,
payload apsPayload,
) {
body, err := json.Marshal(payload)
if err != nil {
slog.Error("apns: marshal payload failed", "err", err)
return
}
reqURL := fmt.Sprintf("%s/3/device/%s", s.base, deviceToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
slog.Error("apns: build request failed", "err", err)
return
}
req.Header.Set("Authorization", "bearer "+jwtStr)
req.Header.Set("apns-topic", s.topic)
req.Header.Set("apns-push-type", "alert")
req.Header.Set("apns-priority", "10")
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
slog.Warn("apns: send failed", "token_prefix", tokenPrefix(deviceToken), "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
LastUsedAt: time.Now().Unix(),
ID: subID,
})
return
}
// Non-2xx: APNs returns a small JSON body whose `reason` names the exact failure
// (BadDeviceToken, InvalidProviderToken, BadTopic, ExpiredProviderToken, …). It is
// the single most useful diagnostic, so always surface it rather than logging a
// bare numeric status.
reason := apnsReason(resp.Body)
switch {
case resp.StatusCode == http.StatusGone, reason == "BadDeviceToken", reason == "DeviceTokenNotForTopic":
// 410 Unregistered, or a permanently-invalid token (the app was uninstalled,
// the token rotated, or it's malformed / for the wrong topic). Prune the row
// so we stop re-firing a guaranteed-fail request on every future Notify.
if err := s.queries.DeletePushSubscription(ctx, subID); err != nil {
slog.Error("apns: prune dead token failed", "id", subID, "reason", reason, "err", err)
}
case reason == "ExpiredProviderToken":
// Clock skew pushed the cached provider JWT past Apple's 60-min limit before
// our 50-min refresh. Bust the cache so the next call re-mints immediately;
// otherwise every push fails for the rest of the refresh window.
s.mu.Lock()
s.cachedJWT = ""
s.mu.Unlock()
slog.Warn("apns: provider token expired, busting cache", "token_prefix", tokenPrefix(deviceToken))
default:
slog.Warn("apns: rejected",
"status", resp.StatusCode, "reason", reason, "token_prefix", tokenPrefix(deviceToken))
}
}
// apnsReason extracts the `reason` field from an APNs error response body (small
// JSON like {"reason":"BadDeviceToken"}). Returns "" if absent or unparseable.
func apnsReason(body io.Reader) string {
var e struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(io.LimitReader(body, 1024)).Decode(&e)
return e.Reason
}
// providerJWT returns a cached ES256 provider JWT, re-minting when the cached
// token is older than jwtRefreshAge. Safe for concurrent callers (mutex-protected).
func (s *Sender) providerJWT() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cachedJWT != "" && time.Since(s.jwtMintedAt) < jwtRefreshAge {
return s.cachedJWT, nil
}
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": s.teamID,
"iat": now.Unix(),
})
tok.Header["kid"] = s.keyID
signed, err := tok.SignedString(s.key)
if err != nil {
return "", fmt.Errorf("sign apns jwt: %w", err)
}
s.cachedJWT = signed
s.jwtMintedAt = now
return signed, nil
}
// tokenPrefix returns the first 8 hex chars of a device token for log
// messages — enough to correlate without exposing the full opaque token.
func tokenPrefix(token string) string {
if len(token) <= 8 {
return token
}
return token[:8] + "..."
}
+396
View File
@@ -0,0 +1,396 @@
package apns
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded
// PKCS8 bytes alongside the typed private key (for verification).
func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
return pemBytes, priv
}
// openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a
// *db.Queries backed by that DB.
func openTestDB(t *testing.T) *db.Queries {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db"))
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return db.New(conn)
}
// TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing
// config field is inert: Enabled reports false and Notify is a safe no-op.
func TestNewSenderInertWhenUnconfigured(t *testing.T) {
keyPEM, _ := generateTestKey(t)
cases := []struct {
name string
keyPEM []byte
keyID, teamID, topic string
}{
{"no key", nil, "KID", "TID", "topic"},
{"no keyID", keyPEM, "", "TID", "topic"},
{"no teamID", keyPEM, "KID", "", "topic"},
{"no topic", keyPEM, "KID", "TID", ""},
{"all empty", nil, "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox")
if s.Enabled() {
t.Fatal("inert Sender should report Enabled=false")
}
// Notify must be a safe no-op with nil queries.
s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage})
})
}
}
// TestNilSenderIsSafe verifies that a nil *Sender is safe to call.
func TestNilSenderIsSafe(t *testing.T) {
var s *Sender
if s.Enabled() {
t.Fatal("nil sender should report disabled")
}
s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic
}
// TestProviderJWT verifies the JWT structure and ES256 signature.
func TestProviderJWT(t *testing.T) {
const keyID = "TESTKEYID1"
const teamID = "TEAMID1234"
keyPEM, priv := generateTestKey(t)
s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox")
if !s.Enabled() {
t.Fatal("sender should be enabled with a valid key")
}
jwtStr, err := s.providerJWT()
if err != nil {
t.Fatalf("providerJWT: %v", err)
}
if jwtStr == "" {
t.Fatal("providerJWT returned empty string")
}
// Parse without verification first to inspect headers.
tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) {
return &priv.PublicKey, nil
})
if err != nil {
t.Fatalf("parse jwt: %v", err)
}
if !tok.Valid {
t.Fatal("jwt should be valid")
}
// Header checks.
alg, _ := tok.Header["alg"].(string)
if alg != "ES256" {
t.Errorf("header alg = %q, want ES256", alg)
}
kid, _ := tok.Header["kid"].(string)
if kid != keyID {
t.Errorf("header kid = %q, want %q", kid, keyID)
}
// Claims checks.
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
t.Fatal("claims not MapClaims")
}
iss, _ := claims["iss"].(string)
if iss != teamID {
t.Errorf("claim iss = %q, want %q", iss, teamID)
}
iat, ok := claims["iat"]
if !ok {
t.Error("claim iat missing")
}
// iat should be a recent unix timestamp (within the last minute).
iatF, _ := iat.(float64)
iatTime := time.Unix(int64(iatF), 0)
if time.Since(iatTime) > time.Minute {
t.Errorf("claim iat too old: %v", iatTime)
}
}
// TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the
// same token string, and that forcing the cache stale mints a new one.
func TestJWTIsCached(t *testing.T) {
keyPEM, _ := generateTestKey(t)
s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox")
first, err := s.providerJWT()
if err != nil {
t.Fatalf("first jwt: %v", err)
}
second, err := s.providerJWT()
if err != nil {
t.Fatalf("second jwt: %v", err)
}
if first != second {
t.Error("providerJWT should return the cached token within refresh window")
}
// Expire the cache.
s.mu.Lock()
s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second))
s.mu.Unlock()
third, err := s.providerJWT()
if err != nil {
t.Fatalf("third jwt: %v", err)
}
if third == first {
t.Error("providerJWT should mint a new token after refresh window expires")
}
}
// apnsServerRecorder is a minimal httptest server that records the last request
// and responds with a configurable status.
type apnsServerRecorder struct {
srv *httptest.Server
lastPath string
lastAuth string
lastTopic string
lastPushType string
lastBody []byte
respondWith int
}
func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder {
t.Helper()
rec := &apnsServerRecorder{respondWith: respondWith}
rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.lastPath = r.URL.Path
rec.lastAuth = r.Header.Get("Authorization")
rec.lastTopic = r.Header.Get("apns-topic")
rec.lastPushType = r.Header.Get("apns-push-type")
rec.lastBody, _ = io.ReadAll(r.Body)
w.WriteHeader(rec.respondWith)
}))
t.Cleanup(rec.srv.Close)
return rec
}
// TestSendRequest asserts that Notify fires a POST with the correct path and
// headers against the APNs base URL, and that the JSON body has the expected shape.
func TestSendRequest(t *testing.T) {
const deviceToken = "abc123deadbeef"
const topic = "com.example.cdrop"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusOK)
s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox")
if !s.Enabled() {
t.Fatal("sender must be enabled")
}
// Point the sender at the test server instead of the real APNs endpoint.
s.base = rec.srv.URL
// Insert an APNs subscription row for user "u", device "d".
now := time.Now().Unix()
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(deviceToken),
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
Tag: "transfer:1",
URL: "/transfers",
})
// Path must be /3/device/{token}.
wantPath := "/3/device/" + deviceToken
if rec.lastPath != wantPath {
t.Errorf("path = %q, want %q", rec.lastPath, wantPath)
}
// Authorization header must start with "bearer ".
if !strings.HasPrefix(rec.lastAuth, "bearer ") {
t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth)
}
// APNs headers.
if rec.lastTopic != topic {
t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic)
}
if rec.lastPushType != "alert" {
t.Errorf("apns-push-type = %q, want alert", rec.lastPushType)
}
// JSON body shape.
var body apsPayload
if err := json.Unmarshal(rec.lastBody, &body); err != nil {
t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody)
}
if body.Type != push.KindTransferIncoming {
t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming)
}
if body.APS.Alert.Title != "Incoming file" {
t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title)
}
if !strings.Contains(body.APS.Alert.Body, "a.pdf") {
t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body)
}
if body.URL != "/transfers" {
t.Errorf("body.url = %q, want /transfers", body.URL)
}
if body.APS.Sound != "default" {
t.Errorf("body.aps.sound = %q, want default", body.APS.Sound)
}
}
// TestSend410PrunesRow verifies that an HTTP 410 response from the APNs
// endpoint causes the subscription row to be deleted from the database.
func TestSend410PrunesRow(t *testing.T) {
const deviceToken = "deadbeef410token"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered
s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox")
s.base = rec.srv.URL
now := time.Now().Unix()
subID := push.SubscriptionID(deviceToken)
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: subID,
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindMessage,
Params: map[string]string{
"sender": "Phone",
"text": "hello",
},
})
// The row must be gone.
subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "d",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 0 {
t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs))
}
}
// TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are
// invisible to ListAPNsSubscriptionsByUserDevice and vice versa.
func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) {
q := openTestDB(t)
now := time.Now().Unix()
// Insert a webpush row.
const webEndpoint = "https://fcm.googleapis.com/fake"
if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{
ID: push.SubscriptionID(webEndpoint),
UserID: "u",
DeviceName: "d",
Endpoint: webEndpoint,
P256dh: "p",
Auth: "a",
UserAgent: "",
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert webpush: %v", err)
}
// APNs query must not see the webpush row.
apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list apns: %v", err)
}
if len(apnsSubs) != 0 {
t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs))
}
// WebPush query must not see apns rows.
const apnsToken = "aabbccdd1234"
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(apnsToken),
UserID: "u",
DeviceName: "d",
Endpoint: apnsToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert apns: %v", err)
}
webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list webpush: %v", err)
}
if len(webSubs) != 1 {
t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs))
}
}