feat: contact form backend for linux and freebsd servers
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package storage provides file-based persistence for nuntius.
//
// Today it contains only the newsletter subscriber log. It is designed
// to be zero-dependency (stdlib only) and crash-safe: each Append writes
// a single JSON line to an append-only file, so partial writes do not
// corrupt earlier records.
package storage
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Subscriber is a single newsletter signup, one JSON object per line.
type Subscriber struct {
Email string `json:"email"`
IP string `json:"ip,omitempty"`
Form string `json:"form"`
CreatedAt time.Time `json:"created_at"`
}
// NewsletterStore is a file-based append-only log of subscribers.
// All methods are safe for concurrent use.
type NewsletterStore struct {
path string
mu sync.Mutex
}
// NewNewsletterStore returns a store that appends to path.
// The file and its parent directory are created lazily on first Append.
func NewNewsletterStore(path string) *NewsletterStore {
return &NewsletterStore{path: path}
}
// Path returns the file path this store writes to.
func (s *NewsletterStore) Path() string {
return s.path
}
// Append writes sub as a single JSON line to the log.
// The file and parent directory are created on first call.
func (s *NewsletterStore) Append(sub Subscriber) error {
if sub.CreatedAt.IsZero() {
sub.CreatedAt = time.Now().UTC()
}
line, err := json.Marshal(sub)
if err != nil {
return fmt.Errorf("marshal subscriber: %w", err)
}
line = append(line, '\n')
s.mu.Lock()
defer s.mu.Unlock()
return appendLine(s.path, line)
}
// Count returns the number of valid subscriber lines in the log.
// Malformed lines are silently skipped so a partial write does not
// brick the entire file.
func (s *NewsletterStore) Count() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.countLocked()
}
func (s *NewsletterStore) countLocked() (int, error) {
f, err := os.Open(s.path)
if os.IsNotExist(err) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
n := 0
scanner := bufio.NewScanner(f)
// Allow up to 1 MB per line in case a single record balloons.
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
// Skip malformed lines rather than failing the whole count.
continue
}
if sub.Email != "" {
n++
}
}
if err := scanner.Err(); err != nil {
return n, fmt.Errorf("scan %s: %w", s.path, err)
}
return n, nil
}
// List returns all valid subscribers in insertion order.
// Use with care on large files; it reads the whole log into memory.
func (s *NewsletterStore) List() ([]Subscriber, error) {
s.mu.Lock()
defer s.mu.Unlock()
f, err := os.Open(s.path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("open %s: %w", s.path, err)
}
defer f.Close()
var out []Subscriber
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
continue
}
if sub.Email != "" {
out = append(out, sub)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %s: %w", s.path, err)
}
return out, nil
}
// DedupeNewsletterStore wraps a NewsletterStore with an in-memory index of
// recorded addresses so callers can detect a repeat subscription before
// doing any user-visible work. The index is built lazily from the log on
// first use and kept in sync on successful appends.
//
// Addresses are compared case-insensitively. Strictly speaking the local
// part of an address may be case-sensitive, but every major provider treats
// mailbox names that way in practice, and bot submissions exploit
// exact-case variants to multiply signups.
type DedupeNewsletterStore struct {
store *NewsletterStore
mu sync.Mutex
seen map[string]struct{}
once sync.Once
}
// NewDedupeNewsletterStore wraps store.
func NewDedupeNewsletterStore(store *NewsletterStore) *DedupeNewsletterStore {
return &DedupeNewsletterStore{store: store}
}
// Path returns the wrapped store's file path.
func (d *DedupeNewsletterStore) Path() string {
return d.store.Path()
}
// Count returns the number of valid records in the log.
func (d *DedupeNewsletterStore) Count() (int, error) {
return d.store.Count()
}
// List returns all valid subscribers in insertion order.
func (d *DedupeNewsletterStore) List() ([]Subscriber, error) {
return d.store.List()
}
// seenKey normalises an address for comparison.
func seenKey(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// load populates the index once per process lifetime. An unreadable log
// behaves like an empty index: dedupe then only covers this run, which
// matches how the process would behave after a hard crash anyway.
func (d *DedupeNewsletterStore) load() {
d.once.Do(func() {
d.seen = make(map[string]struct{})
subs, err := d.store.List()
if err != nil {
return
}
for _, sub := range subs {
d.seen[seenKey(sub.Email)] = struct{}{}
}
})
}
// Has reports whether the address was already recorded.
func (d *DedupeNewsletterStore) Has(email string) bool {
d.load()
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.seen[seenKey(email)]
return ok
}
// Remember records an address after its append succeeded.
func (d *DedupeNewsletterStore) Remember(sub Subscriber) {
d.load()
d.mu.Lock()
defer d.mu.Unlock()
d.seen[seenKey(sub.Email)] = struct{}{}
}
// Append persists sub and records the address on success.
func (d *DedupeNewsletterStore) Append(sub Subscriber) error {
if err := d.store.Append(sub); err != nil {
return err
}
d.Remember(sub)
return nil
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewsletterStore_AppendCreatesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs", "subscribers.jsonl")
s := NewNewsletterStore(path)
if err := s.Append(Subscriber{Email: "a@example.com", Form: "newsletter"}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file to exist, got %v", err)
}
}
func TestNewsletterStore_AppendAndCount(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
for i, e := range []string{"a@x.com", "b@x.com", "c@x.com"} {
if err := s.Append(Subscriber{Email: e, Form: "newsletter"}); err != nil {
t.Fatalf("Append #%d: %v", i, err)
}
}
n, err := s.Count()
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 3 {
t.Errorf("expected 3 subscribers, got %d", n)
}
}
func TestNewsletterStore_AppendIsOneLinePerRecord(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
for _, e := range []string{"a@x.com", "b@x.com"} {
if err := s.Append(Subscriber{Email: e, Form: "n"}); err != nil {
t.Fatalf("Append: %v", err)
}
}
// Each line should be a self-contained JSON object.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
count := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var sub Subscriber
if err := json.Unmarshal(line, &sub); err != nil {
t.Fatalf("Unmarshal line: %v", err)
}
count++
}
if err := scanner.Err(); err != nil {
t.Fatalf("scanner.Err: %v", err)
}
if count != 2 {
t.Errorf("expected 2 records, decoded %d", count)
}
}
func TestNewsletterStore_CountOnMissingFileIsZero(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.jsonl")
s := NewNewsletterStore(path)
n, err := s.Count()
if err != nil {
t.Fatalf("Count on missing file: %v", err)
}
if n != 0 {
t.Errorf("expected 0 on missing file, got %d", n)
}
}
func TestNewsletterStore_ListReturnsInsertionOrder(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
s := NewNewsletterStore(path)
want := []string{"a@x.com", "b@x.com", "c@x.com"}
for _, e := range want {
_ = s.Append(Subscriber{Email: e, Form: "n"})
}
subs, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(subs) != len(want) {
t.Fatalf("expected %d, got %d", len(want), len(subs))
}
for i, sub := range subs {
if sub.Email != want[i] {
t.Errorf("position %d: want %q, got %q", i, want[i], sub.Email)
}
}
}
func TestNewsletterStore_SkipsMalformedLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subs.jsonl")
// Write a mix of valid and garbage lines.
content := `{"email":"good1@x.com","form":"n","created_at":"2026-01-01T00:00:00Z"}
this is not valid json
{"email":"good2@x.com","form":"n","created_at":"2026-01-02T00:00:00Z"}
{not even close to json
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewNewsletterStore(path)
n, err := s.Count()
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 2 {
t.Errorf("expected 2 valid records (skipping malformed), got %d", n)
}
}
func TestNewsletterStore_PathReturnsConfiguredPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "subs.jsonl")
s := NewNewsletterStore(path)
if got := s.Path(); got != path {
t.Errorf("Path: want %q, got %q", path, got)
}
}
// The dedupe wrapper records delivered addresses and reports repeats
// case-insensitively; underlying Append semantics stay untouched.
func TestDedupeNewsletterStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "subs.jsonl")
store := NewNewsletterStore(path)
dedupe := NewDedupeNewsletterStore(store)
if dedupe.Has("Jane@Example.com") {
t.Fatal("fresh log should not contain the address")
}
if err := dedupe.Append(Subscriber{Email: "jane@example.com"}); err != nil {
t.Fatalf("append: %v", err)
}
if !dedupe.Has(" Jane@Example.COM ") {
t.Error("address should be found after append, ignoring case and spaces")
}
n, err := store.Count()
if err != nil || n != 1 {
t.Fatalf("log lines = %d (err %v), want exactly one record", n, err)
}
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Double opt-in support: addresses wait in a pending file until their
// confirmation token is redeemed, then move into the main subscriber log.
package storage
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// PendingTTL is how long an unconfirmed subscription stays actionable.
// After that it is purged and the address must be signed up again.
const PendingTTL = 72 * time.Hour
// RandomToken returns a 32-byte cryptographically random value hex-encoded
// for use inside URLs. Only its SHA-256 hash is persisted; the raw value
// lives exclusively in the confirmation link.
func RandomToken() (string, error) {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("generate confirmation token: %w", err)
}
return hex.EncodeToString(buf[:]), nil
}
type PendingSubscription struct {
Email string `json:"email"`
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type pendingFile struct {
Schema int `json:"schema"`
Entries map[string]PendingSubscription `json:"entries"`
}
const pendingSchema = 1
// PendingStore keeps unconfirmed newsletter subscriptions keyed by the hash
// of their confirmation token. The whole set is rewritten atomically on
// every change: pending files stay tiny (only signups within the TTL), so
// the append-only trick is not needed here.
type PendingStore struct {
mu sync.Mutex
path string
ttl time.Duration
}
// NewPendingStore wraps path with the given entry lifetime.
func NewPendingStore(path string, ttl time.Duration) *PendingStore {
if ttl <= 0 {
ttl = PendingTTL
}
return &PendingStore{path: path, ttl: ttl}
}
// Path returns the backing file location.
func (p *PendingStore) Path() string { return p.path }
// TTL returns the entry lifetime the store enforces.
func (p *PendingStore) TTL() time.Duration { return p.ttl }
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// load reads the file, prunes expired entries and persists the pruned set.
// A missing or corrupt file behaves like an empty store.
func (p *PendingStore) load(now time.Time) (map[string]PendingSubscription, error) {
f := pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{}}
raw, err := os.ReadFile(p.path)
switch {
case err == nil:
if err := json.Unmarshal(raw, &f); err != nil || f.Schema != pendingSchema {
return f.Entries, fmt.Errorf("unreadable pending store %s", p.path)
}
case os.IsNotExist(err):
default:
return f.Entries, fmt.Errorf("read pending store %s: %w", p.path, err)
}
dirty := false
for k, e := range f.Entries {
if now.Sub(e.CreatedAt) > p.ttl || e.CreatedAt.After(now.Add(time.Hour)) {
delete(f.Entries, k)
dirty = true
}
}
if dirty {
if werr := p.save(f); werr != nil {
return f.Entries, werr
}
}
return f.Entries, nil
}
func (p *PendingStore) save(f pendingFile) error {
line, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("marshal pending store: %w", err)
}
if err := os.MkdirAll(filepath.Dir(p.path), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(p.path), err)
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, line, 0o600); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
return os.Rename(tmp, p.path)
}
// Issue stores a new pending subscription keyed by the token hash,
// superseding any earlier entry for the same address. A load warning
// (unreadable file) is non-fatal: the new entry is still written.
func (p *PendingStore) Issue(rawToken string, sub PendingSubscription) error {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
for k, e := range entries {
if seenKey(e.Email) == seenKey(sub.Email) && k != hashToken(rawToken) {
delete(entries, k) // one live token per address
}
}
sub.CreatedAt = now.UTC()
entries[hashToken(rawToken)] = sub
return p.save(pendingFile{Schema: pendingSchema, Entries: entries})
}
// Consume redeems a token: a valid, unexpired entry is removed from the
// file and returned. Unknown tokens, already-redeemed tokens and expired
// entries all report false.
func (p *PendingStore) Consume(rawToken string) (PendingSubscription, bool) {
p.mu.Lock()
defer p.mu.Unlock()
key := hashToken(rawToken)
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[key]
if !ok {
return PendingSubscription{}, false
}
delete(entries, key)
_ = p.save(pendingFile{Schema: pendingSchema, Entries: entries})
if now.Sub(sub.CreatedAt) > p.ttl {
return PendingSubscription{}, false
}
return sub, true
}
// Peek returns the subscription behind rawToken without consuming it,
// reporting false when the token is unknown or expired.
func (p *PendingStore) Peek(rawToken string) (PendingSubscription, bool) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[hashToken(rawToken)]
if !ok || now.Sub(sub.CreatedAt) > p.ttl {
return PendingSubscription{}, false
}
return sub, true
}
// HasToken reports whether raw is still a live, redeemable token.
func (p *PendingStore) HasToken(rawToken string) bool {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[hashToken(rawToken)]
return ok && now.Sub(sub.CreatedAt) <= p.ttl
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPendingStoreRoundTrip(t *testing.T) {
p := NewPendingStore(filepath.Join(t.TempDir(), "pending.json"), 72*time.Hour)
if _, ok := p.Consume("unknown-token"); ok {
t.Fatal("unknown token must not consume")
}
if err := p.Issue("token-a", PendingSubscription{Email: "Jane@Example.COM", IP: "203.0.113.9"}); err != nil {
t.Fatalf("issue: %v", err)
}
sub, ok := p.Consume("token-a")
if !ok {
t.Fatal("valid token must consume")
}
if sub.Email != "Jane@Example.COM" || sub.IP != "203.0.113.9" {
t.Errorf("consumed entry = %+v", sub)
}
if _, ok := p.Consume("token-a"); ok {
t.Error("token must be single-use")
}
}
// An expired entry is never returned and is purged from the file.
func TestPendingStoreExpiry(t *testing.T) {
path := filepath.Join(t.TempDir(), "pending.json")
stale, _ := json.Marshal(pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{
hashToken("old"): {Email: "a@b.c", CreatedAt: time.Now().Add(-96 * time.Hour)},
}})
if err := os.WriteFile(path, stale, 0o600); err != nil {
t.Fatal(err)
}
if _, ok := NewPendingStore(path, 72*time.Hour).Consume("old"); ok {
t.Fatal("expired token must not consume")
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), `"a@b.c"`) {
t.Error("expired entry was not purged from the file")
}
}
// Issue replaces the previous live token for the same address.
func TestPendingStoreRotation(t *testing.T) {
path := filepath.Join(t.TempDir(), "pending.json")
p := NewPendingStore(path, 72*time.Hour)
if err := p.Issue("first", PendingSubscription{Email: "jane@example.com"}); err != nil {
t.Fatal(err)
}
if err := p.Issue("second", PendingSubscription{Email: "jane@example.com"}); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(path)
if strings.Count(string(raw), "@") != 1 {
t.Errorf("expected a single live entry after rotation, got %s", raw)
}
if _, ok := p.Consume("first"); ok {
t.Error("superseded token must no longer work")
}
if _, ok := p.Consume("second"); !ok {
t.Error("current token must still work")
}
}
func TestRandomTokenIsHexAndUnique(t *testing.T) {
a, err := RandomToken()
if err != nil {
t.Fatal(err)
}
b, err := RandomToken()
if err != nil {
t.Fatal(err)
}
if len(a) != 64 || a == b {
t.Errorf("tokens = %q %q, want unique 64-char hex", a, b)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Submission is one archived form submission, one JSON object per line.
// Newsletter forms do not write here: they persist through the double
// opt-in subscriber log instead.
type Submission struct {
Form string `json:"form"`
Name string `json:"name,omitempty"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ArchiveStore is a file-based append-only log of submissions. All methods
// are safe for concurrent use. The file and its parent directory are
// created lazily on first Append, so a form that archives nothing writes
// nothing.
type ArchiveStore struct {
path string
mu sync.Mutex
}
// NewArchiveStore returns a store that appends to path.
func NewArchiveStore(path string) *ArchiveStore {
return &ArchiveStore{path: path}
}
// Path returns the file path this store writes to.
func (s *ArchiveStore) Path() string {
return s.path
}
// Append writes sub as a single JSON line to the log.
func (s *ArchiveStore) Append(sub Submission) error {
if sub.CreatedAt.IsZero() {
sub.CreatedAt = time.Now().UTC()
}
line, err := json.Marshal(sub)
if err != nil {
return fmt.Errorf("marshal submission: %w", err)
}
line = append(line, '\n')
s.mu.Lock()
defer s.mu.Unlock()
return appendLine(s.path, line)
}
// appendLine writes one marshalled JSON line to an append-only log,
// creating the file and its parent directory on first use. The caller
// holds the store's lock.
func appendLine(path string, line []byte) (err error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
if _, err := f.Write(line); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestArchiveStoreRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "archive-test.jsonl")
store := NewArchiveStore(path)
first := Submission{
Form: "contact",
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss an engagement.",
IP: "192.0.2.1",
}
if err := store.Append(first); err != nil {
t.Fatalf("append: %v", err)
}
second := Submission{Form: "feedback", Email: "other@example.com", Message: "Short"}
if err := store.Append(second); err != nil {
t.Fatalf("append: %v", err)
}
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
var got []Submission
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var sub Submission
if err := json.Unmarshal(scanner.Bytes(), &sub); err != nil {
t.Fatalf("unmarshal line: %v", err)
}
got = append(got, sub)
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan: %v", err)
}
if len(got) != 2 {
t.Fatalf("lines = %d, want 2", len(got))
}
if got[0].CreatedAt.IsZero() {
t.Errorf("first created_at is zero, want a stamped time")
}
first.CreatedAt = got[0].CreatedAt
if got[0] != first {
t.Errorf("first = %+v, want %+v", got[0], first)
}
if got[1].CreatedAt.IsZero() {
t.Errorf("second created_at is zero, want a stamped time")
}
}