go-e2e-hash-only-open-no-credit
Two independent defects combine to make hash-only /open never accrue claimable credits.
Defect 1 — open.go:120-123 gates RecordCredit on body fields the spec doesn't send. The documented spec OpenPageRequest carries only hash; PublisherID/Domain are never present, so the gate if req.PublisherID != "" && req.Domain != "" is always false and no credit row is ever created. Fix: drop the body gating and resolve hash → URL → domain → publisher from content_hash_store + verified_domains, then credit the resolved publisher.
// open.go — spec-compliant request carries ONLY hash
type OpenPageRequest struct {
Hash string `json:"hash" binding:"required"`
}
type OpenPageResponse struct {
OK bool `json:"ok"`
URL string `json:"url,omitempty"`
Credited bool `json:"credited"`
}
func (s *Server) Open(c *gin.Context) {
var req OpenPageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "hash required"})
return
}
// 1. hash -> url (content_hash_store), case-insensitive
url, err := s.contentHashes.URLByHash(strings.ToLower(req.Hash))
if err != nil || url == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown content hash"})
return
}
// 2. url -> domain
u, err := urlpkg.Parse(url)
if err != nil || u.Host == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "unresolvable url"})
return
}
// 3. domain -> publisher (verified_domains)
publisher, ok, err := s.verifiedDomains.PublisherByDomain(u.Host)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "domain lookup failed"})
return
}
if !ok {
// open still recorded; domain not verified -> nothing claimable
c.JSON(http.StatusOK, OpenPageResponse{OK: true, URL: url, Credited: false})
return
}
// 4. credit the RESOLVED publisher (previously gated on body fields)
if err := s.credits.RecordCredit(c.Request.Context(), publisher, u.Host, creditPerOpen); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record credit"})
return
}
c.JSON(http.StatusOK, OpenPageResponse{OK: true, URL: url, Credited: true})
}
// RecordCredit: idempotent upsert that accumulates the shadow balance
func (r *CreditRepo) RecordCredit(ctx context.Context, publisherID, domain string, amount int64) error {
const q = `
INSERT INTO publisher_credits (publisher_id, domain, shadow_credits, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (publisher_id, domain)
DO UPDATE SET shadow_credits = publisher_credits.shadow_credits + EXCLUDED.shadow_credits,
updated_at = now()`
_, err := r.db.ExecContext(ctx, q, publisherID, domain, amount)
return err
}
Defect 2 — ClaimDomainCredits UPDATE ... RETURNING shadow_credits returns the post-update value. Setting shadow_credits = 0 and returning the column yields 0, so every claim pays out zero even when shadow credits accrued. Fix: a data-modifying CTE — a snapshot CTE with FOR UPDATE reads the pre-update value (and takes the row lock), the zeroed CTE zeroes it, and the outer query returns the snapshot amount. The whole statement is atomic; the row lock serializes concurrent claims.
func (r *CreditRepo) ClaimDomainCredits(ctx context.Context, publisherID, domain string) (int64, error) {
const q = `
WITH snapshot AS (
SELECT publisher_id, shadow_credits AS amount
FROM publisher_credits
WHERE publisher_id = $1 AND domain = $2
FOR UPDATE
),
zeroed AS (
UPDATE publisher_credits
SET shadow_credits = 0,
updated_at = now()
WHERE publisher_id = $1 AND domain = $2
RETURNING publisher_id
)
SELECT s.amount
FROM snapshot s
JOIN zeroed z ON z.publisher_id = s.publisher_id`
var claimed int64
err := r.db.QueryRowContext(ctx, q, publisherID, domain).Scan(&claimed)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil // nothing accrued
}
return claimed, err
}
Verified against a real **PostgreSQL 18** instance (`initdb` + `pg_ctl`, driven by a Go harness with `database/sql` + `lib/pq`; schema mirrors the service: `content_hash_store`, `verified_domains`, `publisher_credits`). Both defects were reproduced first, then the fix was applied. `go test -v ./...` — **11/11 pass**:
**Defects reproduced (pre-fix):**
- `UPDATE ... RETURNING shadow_credits` on a balance of 100 → returns **0** (post-update value). ✔ reproduced
- `openBuggy` with a hash-only request → `credited=false`, `shadow_credits` stays **0** (body-gate never passes). ✔ reproduced
**Fix verified (post-fix):**
- `claimFixed` on 100 → returns **100**, balance zeroed to 0.
- Hash-only `/open` (body = `{"hash":"abc123"}` only) → resolves to `https://example.com/post/1` → `example.com` → `pub_1`; shadow goes 1 → 2 over two opens; a subsequent claim pays out exactly **2**.
- Sequential double-claim on 100 → **(100, 0)**, never double-spends.
- **Concurrency**: two *simultaneous* claims on 100 → total exactly **100** (`FOR UPDATE` row lock serializes them), balance 0.
**Edge cases tested (all pass):**
- Unknown hash → 404, no credit row created.
- Hash resolves but domain has no verified publisher → 200, `credited=false`, zero credit rows.
- Zero-balance claim → returns 0, no error.
- Case-insensitive hash lookup (`"ABC123"` → stored `abc123`) → credited.
- Malformed URL (`"not a url"` in store) → 422, not credited.{"model": "deepseek-v4-flash", "problem_class": "go-e2e-hash-only-open-no-credit", "result": "passed", "tests": 11}