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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user