Files
orm/zap_adapter_test.go
zeekayandHanzo Dev 0053b64e1b feat(zap): rewire OpenZap onto zap-proto/http transport (live, tested)
OpenZap was stubbed (returned errZapBackendDisabled) because db/zap.go was
written against luxfi/zap's peer-to-peer Node API, which drags in luxfi/mdns
(zeroconf + miekg/dns) — the heavyweight dep the CGO-free port deliberately
dropped. luxfi/zap's Node never landed in zap-proto/go, so the stub could
never be un-stubbed as written.

Rewire the driver onto github.com/zap-proto/http (ZAP-HTTP): a fasthttp-style
request/response over ZAP length-prefixed frames — the same transport the
gateway, ingress, and luxd already use. The driver's call(path, body) ->
(status, body) maps 1:1 onto Transport.Do, so every backend request body
(SQL/KV/DocumentDB/Datastore), zapKey, zapQuery, and zapTransaction stay
identical; only the transport swaps. No mDNS, stays pure-Go / CGO-free.

- OpenZap now mirrors OpenSQLite: NewZapDB -> AdaptDB (one wrap path for
  every backend).
- Delete db/_zap_archive.go (build-tagged never) and db/zap_types_stub.go;
  the restored db/zap.go carries the real ZapConfig/ZapBackend.
- zap_adapter_test.go: an in-process zaphttp.Server KV harness on a loopback
  socket proves create -> get -> update -> delete round-trips over the real
  ZAP binary transport (mirrors adapter_test.go's SQLite coverage).

Also fix a pre-existing, date-triggered engine bug: time.Now() bound as a SQL
arg was stored via the driver's %v/String() form (with the monotonic "m=+..."
suffix), which is unparseable on scan. Normalize time.Time args to UTC
RFC3339Nano at the single exec/query/queryRow boundary. Fixes 5 engine
time-scan tests.

Proof: CGO_ENABLED=0 go build ./... && go test ./... — all packages green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-17 00:59:30 -07:00

304 lines
6.6 KiB
Go

package orm
import (
"encoding/json"
"net"
"sync"
"testing"
ormdb "github.com/hanzoai/orm/db"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
)
// kvStore is an in-memory Valkey stand-in that speaks the exact ZAP-KV wire
// protocol the ORM's ZAP driver emits:
//
// POST /set {"key","value"} -> 200
// POST /get {"key"} -> 200 + raw value bytes, or 404
// POST /cmd {"cmd":"DEL","args":[…]} -> 200
//
// It is served by a real zaphttp.Server over a loopback TCP listener, so the
// driver exercises the genuine ZAP binary transport (frame codec + framing +
// socket) end to end — this is the in-process ZAP test harness, not a mock of
// the driver.
type kvStore struct {
mu sync.Mutex
data map[string]string
}
func (s *kvStore) handle(ctx *fasthttp.RequestCtx) {
switch string(ctx.Path()) {
case "/set":
var m struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.Unmarshal(ctx.PostBody(), &m); err != nil {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
return
}
s.mu.Lock()
s.data[m.Key] = m.Value
s.mu.Unlock()
ctx.SetStatusCode(fasthttp.StatusOK)
case "/get":
var m struct {
Key string `json:"key"`
}
if err := json.Unmarshal(ctx.PostBody(), &m); err != nil {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
return
}
s.mu.Lock()
v, ok := s.data[m.Key]
s.mu.Unlock()
if !ok {
ctx.SetStatusCode(fasthttp.StatusNotFound)
return
}
ctx.SetStatusCode(fasthttp.StatusOK)
ctx.SetBodyString(v)
case "/cmd":
var m struct {
Cmd string `json:"cmd"`
Args []string `json:"args"`
}
if err := json.Unmarshal(ctx.PostBody(), &m); err != nil {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
return
}
if m.Cmd == "DEL" {
s.mu.Lock()
for _, k := range m.Args {
delete(s.data, k)
}
s.mu.Unlock()
}
ctx.SetStatusCode(fasthttp.StatusOK)
default:
ctx.SetStatusCode(fasthttp.StatusNotFound)
}
}
// startZapKVServer stands up the in-process ZAP-KV server on a loopback port
// and returns its host:port.
func startZapKVServer(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
store := &kvStore{data: map[string]string{}}
srv := &zaphttp.Server{Handler: store.handle}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { srv.Close() })
return ln.Addr().String()
}
// newTestZapKV opens an orm.DB over the ZAP protocol against the in-process KV
// server. Mirrors newTestSQLite so the ZAP path is proven with the same entity
// and the same lifecycle as the SQLite path.
func newTestZapKV(t *testing.T) DB {
t.Helper()
db, err := OpenKV(&ormdb.ZapConfig{Addr: startZapKVServer(t)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestZapAdapterCreate(t *testing.T) {
db := newTestZapKV(t)
user := New[intgUser](db)
user.Name = "Alice"
user.Email = "[email protected]"
user.Age = 30
if err := user.Create(); err != nil {
t.Fatal("Create failed:", err)
}
if user.Id() == "" {
t.Error("expected non-empty ID after create")
}
if user.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
}
func TestZapAdapterGetById(t *testing.T) {
db := newTestZapKV(t)
user := New[intgUser](db)
user.Name = "Bob"
user.Email = "[email protected]"
user.Age = 25
if err := user.Create(); err != nil {
t.Fatal(err)
}
got, err := Get[intgUser](db, user.Id())
if err != nil {
t.Fatal("Get failed:", err)
}
if got.Name != "Bob" {
t.Errorf("expected Bob, got %s", got.Name)
}
if got.Email != "[email protected]" {
t.Errorf("expected [email protected], got %s", got.Email)
}
if got.Age != 25 {
t.Errorf("expected age 25, got %d", got.Age)
}
}
func TestZapAdapterUpdate(t *testing.T) {
db := newTestZapKV(t)
user := New[intgUser](db)
user.Name = "Charlie"
user.Email = "[email protected]"
user.Age = 35
if err := user.Create(); err != nil {
t.Fatal(err)
}
got, err := Get[intgUser](db, user.Id())
if err != nil {
t.Fatal(err)
}
got.Name = "Charlie Updated"
got.Age = 36
if err := got.Update(); err != nil {
t.Fatal("Update failed:", err)
}
got2, err := Get[intgUser](db, user.Id())
if err != nil {
t.Fatal(err)
}
if got2.Name != "Charlie Updated" {
t.Errorf("expected 'Charlie Updated', got %s", got2.Name)
}
if got2.Age != 36 {
t.Errorf("expected age 36, got %d", got2.Age)
}
}
func TestZapAdapterDelete(t *testing.T) {
db := newTestZapKV(t)
user := New[intgUser](db)
user.Name = "Dave"
user.Email = "[email protected]"
if err := user.Create(); err != nil {
t.Fatal(err)
}
if err := user.Delete(); err != nil {
t.Fatal("Delete failed:", err)
}
_, err := Get[intgUser](db, user.Id())
if err != ErrNotFound {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
func TestZapAdapterModelQuery(t *testing.T) {
db := newTestZapKV(t)
user := New[intgUser](db)
user.Name = "QueryUser"
user.Email = "[email protected]"
user.Age = 30
if err := user.Create(); err != nil {
t.Fatal(err)
}
q := TypedQuery[intgUser](db)
found, err := q.ById(user.Id())
if err != nil {
t.Fatal(err)
}
if !found {
t.Error("expected to find entity by ID")
}
}
func TestZapAdapterExists(t *testing.T) {
db := newTestZapKV(t)
user := New[intgUser](db)
user.Name = "ExistsUser"
user.Email = "[email protected]"
if err := user.Create(); err != nil {
t.Fatal(err)
}
exists, err := user.Exists()
if err != nil {
t.Fatal(err)
}
if !exists {
t.Error("expected entity to exist")
}
}
func TestZapAdapterHooks(t *testing.T) {
db := newTestZapKV(t)
entity := New[intgHooked](db)
entity.Name = "Hooked"
if err := entity.Create(); err != nil {
t.Fatal(err)
}
if !entity.BeforeHook {
t.Error("expected BeforeCreate hook to fire")
}
if !entity.AfterHook {
t.Error("expected AfterCreate hook to fire")
}
got, err := Get[intgHooked](db, entity.Id())
if err != nil {
t.Fatal(err)
}
if !got.BeforeHook {
t.Error("expected BeforeHook=true in stored entity")
}
}
// TestOpenZapGeneric proves the generic OpenZap entry point (not just the
// OpenKV convenience wrapper) opens a working orm.DB over the ZAP path.
func TestOpenZapGeneric(t *testing.T) {
db, err := OpenZap(&ormdb.ZapConfig{
Addr: startZapKVServer(t),
Backend: ormdb.ZapKV,
})
if err != nil {
t.Fatal("OpenZap failed:", err)
}
t.Cleanup(func() { db.Close() })
user := New[intgUser](db)
user.Name = "Zed"
user.Email = "[email protected]"
user.Age = 40
if err := user.Create(); err != nil {
t.Fatal("Create failed:", err)
}
got, err := Get[intgUser](db, user.Id())
if err != nil {
t.Fatal("Get failed:", err)
}
if got.Name != "Zed" || got.Age != 40 {
t.Errorf("round-trip mismatch: got %+v", got)
}
}