feat(P1): mock store + fixtures + import-invariant test (REQ-040, G-025, G-027)

web/store instantiates real x/identity/types.Reach + x/stash/types.Stash
(app-layer consumption per D-070, NOT a cross-x/ import). CreateReach
atomically creates Reach (IsNomad=true) + Stash (D-071). G-027 validates
HolderID/PublicKey (non-empty, <=128, no path separators, no template
syntax). import_test.go enforces G-025: web/ imports only x/*/types,
never x/*/keeper or x/<module> (module.go). Coverage 100%.

---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
This commit is contained in:
2026-08-18 13:45:05 +00:00
parent 4166de5a4b
commit 5a12ab0e76
4 changed files with 516 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"time"
identitytypes "github.com/oy/openyield/x/identity/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
// seed populates the store with a few pre-existing Reach/Stash pairs for the
// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT
// "account"/"bank"/"deposit"). Two fixtures: one mature (90+ active days),
// one immature (45 active days) so the Stash dashboard (P2) can show both
// states.
func (s *Store) seed() {
now := time.Now().Unix()
// Fixture 1: a mature Nomad (ActiveDays=92, MaxGapDays=10 -> IsMature()).
seedOne(s, "holder-alia", "pk-alia-001", now, 920000, 92, 10)
// Fixture 2: an immature Nomad (ActiveDays=45, MaxGapDays=5 -> not mature).
seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5)
}
func seedOne(s *Store, holderID, pubKey string, now int64, balanceGrain int64, activeDays, maxGap uint32) {
reachID := "reach-" + holderID
stashID := "stash-" + holderID
s.reaches[holderID] = identitytypes.Reach{
ReachID: reachID,
HolderID: holderID,
CreatedAt: now - int64(activeDays)*86400,
PublicKey: pubKey,
IsNomad: true,
}
s.stashes[holderID] = stashtypes.Stash{
HolderID: holderID,
StashID: stashID,
CreatedAt: now - int64(activeDays)*86400,
LastActive: now,
BalanceGrain: balanceGrain,
}
s.stashActivities[stashID] = stashtypes.StashActivity{
StashID: stashID,
ActiveDays: activeDays,
MaxGapDays: maxGap,
LastActivityDay: now,
}
}
+108
View File
@@ -0,0 +1,108 @@
// import_test.go enforces the G-003/G-025 boundary for web/: web/ is the
// application layer that consumes protocol types (D-070), NOT a cross-x/
// production import. The invariant: every non-test .go file under web/ may
// import github.com/oy/openyield/x/<module>/types packages (the app-layer
// consumption direction), but MUST NOT import github.com/oy/openyield/
// x/<module>/keeper OR github.com/oy/openyield/x/<module> (the module.go
// packages — G-025 extends the original keeper-only check to also forbid
// module.go, since those packages carry Cosmos runtime machinery the mock UI
// must not reach into). This test uses go/parser (stdlib only — G-006) and
// mirrors the x/window/types/types_test.go G-003 pattern, but with the
// inverted rule: x/*/types is ALLOWED (app-layer consumption), x/*/keeper
// and x/<module> (module.go) are FORBIDDEN.
package store
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestG025WebImportsOnlyTypesNotKeeperOrModule(t *testing.T) {
webRoot := webRoot(t)
fset := token.NewFileSet()
violations := []string{}
err := filepath.Walk(webRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
// Skip test files (G-025 is about production code only).
if strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if perr != nil {
return perr
}
for _, imp := range f.Imports {
ip := strings.Trim(imp.Path.Value, `"`)
if isForbiddenXImport(ip) {
rel, _ := filepath.Rel(webRoot, path)
violations = append(violations, rel+" -> "+ip)
}
}
return nil
})
if err != nil {
t.Fatalf("walk web/: %v", err)
}
if len(violations) > 0 {
t.Errorf("G-025 violation: web/ production files importing forbidden x/ packages:\n %s",
strings.Join(violations, "\n "))
}
}
// isForbiddenXImport reports whether ip is an x/<module>/keeper or a bare
// x/<module> (module.go) import — both forbidden from web/ (G-025). The
// x/<module>/types packages are ALLOWED (D-070 app-layer consumption).
func isForbiddenXImport(ip string) bool {
const prefix = "github.com/oy/openyield/x/"
if !strings.HasPrefix(ip, prefix) {
return false
}
rest := strings.TrimPrefix(ip, prefix)
parts := strings.Split(rest, "/")
switch len(parts) {
case 1:
// x/<module> (module.go package) — forbidden (G-025).
return true
case 2:
// x/<module>/types -> allowed (D-070). x/<module>/keeper -> forbidden.
if parts[1] == "types" {
return false
}
return true
default:
// x/<module>/<sub>/... — forbid anything other than types (e.g.
// x/<module>/keeper/... sub-packages).
if parts[1] == "types" {
return false
}
return true
}
}
// webRoot returns the absolute path to the web/ directory by walking up
// from this test file (web/store/import_test.go -> repoRoot/web).
func webRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// file = .../oy/web/store/import_test.go
// repoRoot = filepath.Dir(filepath.Dir(filepath.Dir(file)))
// webRoot = repoRoot/web
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(file)))
return filepath.Join(repoRoot, "web")
}
+146
View File
@@ -0,0 +1,146 @@
// Package store is the in-memory mock data layer for the OpenYield web UI.
//
// It instantiates the real x/*/types structs (Reach, Stash, StashActivity)
// from in-memory fixtures and provides create/get/list methods. This is the
// app-layer consumption of protocol types (D-070), NOT a cross-x/ production
// import — web/ is NOT an x/ module. No keeper, no Cosmos runtime, no app.go
// (G-003 boundary enforced by import_test.go / G-025).
package store
import (
"fmt"
"strings"
"sync"
"time"
identitytypes "github.com/oy/openyield/x/identity/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
// seedBalanceGrain is the test balance seeded to a new Stash at signup (D-071
// example: 500000 Grain = 50 Bread per GrainsPerBread=10000).
const seedBalanceGrain int64 = 500000
// Store is the in-memory mock store. All methods are goroutine-safe (mu).
type Store struct {
mu sync.Mutex
reaches map[string]identitytypes.Reach
stashes map[string]stashtypes.Stash
stashActivities map[string]stashtypes.StashActivity
}
// NewStore constructs a Store seeded from fixtures (fixtures.go).
func NewStore() *Store {
s := &Store{
reaches: map[string]identitytypes.Reach{},
stashes: map[string]stashtypes.Stash{},
stashActivities: map[string]stashtypes.StashActivity{},
}
s.seed()
return s
}
// CreateReach atomically creates a Reach (IsNomad=true) + a Stash (D-071).
// G-027: HolderID and PublicKey are validated (non-empty, <=128 bytes, no
// path separators, no template syntax) before any map write. Returns the
// created Reach + Stash.
func (s *Store) CreateReach(holderID, publicKey string) (identitytypes.Reach, stashtypes.Stash, error) {
if err := validateReachInput(holderID, publicKey); err != nil {
return identitytypes.Reach{}, stashtypes.Stash{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, dup := s.reaches[holderID]; dup {
return identitytypes.Reach{}, stashtypes.Stash{}, fmt.Errorf("holder %q already has a Reach", holderID)
}
now := time.Now().Unix()
reachID := "reach-" + holderID
stashID := "stash-" + holderID
reach := identitytypes.Reach{
ReachID: reachID,
HolderID: holderID,
CreatedAt: now,
PublicKey: publicKey,
IsNomad: true,
}
stash := stashtypes.Stash{
HolderID: holderID,
StashID: stashID,
CreatedAt: now,
LastActive: now,
BalanceGrain: seedBalanceGrain,
}
activity := stashtypes.StashActivity{
StashID: stashID,
ActiveDays: 1,
MaxGapDays: 1,
LastActivityDay: now,
}
s.reaches[holderID] = reach
s.stashes[holderID] = stash
s.stashActivities[stashID] = activity
return reach, stash, nil
}
// ListReaches returns all seeded + created Reaches.
func (s *Store) ListReaches() []identitytypes.Reach {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]identitytypes.Reach, 0, len(s.reaches))
for _, r := range s.reaches {
out = append(out, r)
}
return out
}
// GetReach returns the Reach for a holderID (by HolderID, the stable key).
func (s *Store) GetReach(holderID string) (identitytypes.Reach, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.reaches[holderID]
return r, ok
}
// GetStash returns the Stash for a holderID.
func (s *Store) GetStash(holderID string) (stashtypes.Stash, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.stashes[holderID]
return st, ok
}
// GetStashActivity returns the StashActivity for a stashID.
func (s *Store) GetStashActivity(stashID string) (stashtypes.StashActivity, bool) {
s.mu.Lock()
defer s.mu.Unlock()
a, ok := s.stashActivities[stashID]
return a, ok
}
// validateReachInput enforces G-027: HolderID and PublicKey must be non-empty,
// <=128 bytes, and contain no path separators or template syntax. This is a
// prototype-robustness gate (the mock store uses holderID as a map key).
func validateReachInput(holderID, publicKey string) error {
if holderID == "" {
return fmt.Errorf("holder id is required")
}
if len(holderID) > 128 {
return fmt.Errorf("holder id too long (max 128)")
}
if strings.ContainsAny(holderID, "/\\") {
return fmt.Errorf("holder id must not contain path separators")
}
if strings.Contains(holderID, "{{") {
return fmt.Errorf("holder id must not contain template syntax")
}
if publicKey == "" {
return fmt.Errorf("public key is required")
}
if len(publicKey) > 128 {
return fmt.Errorf("public key too long (max 128)")
}
if strings.ContainsAny(publicKey, "/\\") {
return fmt.Errorf("public key must not contain path separators")
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package store
import (
"sync"
"testing"
identitytypes "github.com/oy/openyield/x/identity/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
func TestNewStoreSeedsFixtures(t *testing.T) {
s := NewStore()
reaches := s.ListReaches()
if len(reaches) < 2 {
t.Fatalf("NewStore seeded %d reaches, want >=2", len(reaches))
}
// Both seeded reaches must be Nomads (IsNomad=true).
for _, r := range reaches {
if !r.IsNomad {
t.Errorf("seeded reach %q: IsNomad=false, want true", r.HolderID)
}
}
}
func TestCreateReachAtomicReachAndStash(t *testing.T) {
s := NewStore()
reach, stash, err := s.CreateReach("holder-test1", "pk-test1")
if err != nil {
t.Fatalf("CreateReach: %v", err)
}
// D-071: Reach must be IsNomad=true.
if !reach.IsNomad {
t.Errorf("reach.IsNomad = false, want true (D-071)")
}
if reach.HolderID != "holder-test1" {
t.Errorf("reach.HolderID = %q, want holder-test1", reach.HolderID)
}
// D-071: Stash must have matching HolderID + seeded BalanceGrain.
if stash.HolderID != reach.HolderID {
t.Errorf("stash.HolderID = %q, want %q (D-071 atomic)", stash.HolderID, reach.HolderID)
}
if stash.BalanceGrain != seedBalanceGrain {
t.Errorf("stash.BalanceGrain = %d, want %d", stash.BalanceGrain, seedBalanceGrain)
}
// Both must be retrievable after the atomic call.
if _, ok := s.GetReach("holder-test1"); !ok {
t.Errorf("GetReach miss after CreateReach (atomicity broken)")
}
if _, ok := s.GetStash("holder-test1"); !ok {
t.Errorf("GetStash miss after CreateReach (atomicity broken)")
}
if _, ok := s.GetStashActivity(stash.StashID); !ok {
t.Errorf("GetStashActivity miss after CreateReach (atomicity broken)")
}
}
func TestCreateReachDuplicateRejected(t *testing.T) {
s := NewStore()
if _, _, err := s.CreateReach("holder-alia", "pk-dupe"); err == nil {
t.Errorf("CreateReach duplicate holder-alia: expected error, got nil")
}
}
func TestCreateReachValidationG027(t *testing.T) {
cases := []struct {
name string
holderID string
publicKey string
wantErr bool
}{
{"empty holder", "", "pk", true},
{"empty pubkey", "h", "", true},
{"holder too long", stringOf('x', 129), "pk", true},
{"pubkey too long", "h", stringOf('y', 129), true},
{"holder with slash", "h/x", "pk", true},
{"holder with backslash", "h\\x", "pk", true},
{"holder with template syntax", "h{{", "pk", true},
{"pubkey with slash", "h", "p/x", true},
{"valid minimal", "h", "p", false},
{"valid typical", "holder-oka", "pk-oka-7", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := NewStore()
_, _, err := s.CreateReach(c.holderID, c.publicKey)
if c.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
if !c.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestGetReachHitMiss(t *testing.T) {
s := NewStore()
if _, ok := s.GetReach("holder-alia"); !ok {
t.Errorf("GetReach(holder-alia) miss, want hit (seeded)")
}
if _, ok := s.GetReach("nobody"); ok {
t.Errorf("GetReach(nobody) hit, want miss")
}
}
func TestGetStashHitMiss(t *testing.T) {
s := NewStore()
if _, ok := s.GetStash("holder-alia"); !ok {
t.Errorf("GetStash(holder-alia) miss, want hit (seeded)")
}
if _, ok := s.GetStash("nobody"); ok {
t.Errorf("GetStash(nobody) hit, want miss")
}
}
func TestGetStashActivityHitMiss(t *testing.T) {
s := NewStore()
stash, ok := s.GetStash("holder-alia")
if !ok {
t.Fatal("seeded stash holder-alia missing")
}
if _, ok := s.GetStashActivity(stash.StashID); !ok {
t.Errorf("GetStashActivity(%q) miss, want hit", stash.StashID)
}
if _, ok := s.GetStashActivity("stash-nobody"); ok {
t.Errorf("GetStashActivity(stash-nobody) hit, want miss")
}
}
func TestCreateReachConcurrentNoRace(t *testing.T) {
s := NewStore()
const n = 50
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
holder := "holder-concurrent-" + itoa(i)
_, _, _ = s.CreateReach(holder, "pk")
}(i)
}
wg.Wait()
// All n concurrent creates with distinct holder IDs must be present.
for i := 0; i < n; i++ {
if _, ok := s.GetReach("holder-concurrent-" + itoa(i)); !ok {
t.Errorf("concurrent reach %d missing after wg.Wait", i)
}
}
}
func TestSeededMatureVsImmature(t *testing.T) {
s := NewStore()
// holder-alia: ActiveDays=92, MaxGapDays=10 -> mature.
aliaStash, ok := s.GetStash("holder-alia")
if !ok {
t.Fatal("seeded holder-alia missing")
}
aliaAct, ok := s.GetStashActivity(aliaStash.StashID)
if !ok {
t.Fatal("seeded alia activity missing")
}
if !aliaAct.IsMature() {
t.Errorf("holder-alia IsMature=false, want true (ActiveDays=%d, MaxGap=%d)",
aliaAct.ActiveDays, aliaAct.MaxGapDays)
}
// holder-bryn: ActiveDays=45, MaxGapDays=5 -> not mature.
brynStash, ok := s.GetStash("holder-bryn")
if !ok {
t.Fatal("seeded holder-bryn missing")
}
brynAct, ok := s.GetStashActivity(brynStash.StashID)
if !ok {
t.Fatal("seeded bryn activity missing")
}
if brynAct.IsMature() {
t.Errorf("holder-bryn IsMature=true, want false (ActiveDays=%d, MaxGap=%d)",
brynAct.ActiveDays, brynAct.MaxGapDays)
}
}
// Compile-time assertions that the types are the real x/*/types structs
// (D-067: the mock store grounds the UI in the real Go type definitions).
var _ identitytypes.Reach
var _ stashtypes.Stash
// itoa is a tiny strconv.Itoa without the import (keeps store_test.go deps
// to just sync + testing + the two x/*/types packages).
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
func stringOf(r rune, n int) string {
b := make([]byte, n)
for i := range b {
b[i] = byte(r)
}
return string(b)
}