Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions demos/acme-commerce-lenders/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Check first: `make kit APP=acme-commerce-lenders ARGS=ready`. It lists each app
| An app emulator served by LocalStack shows `unreachable`: "LocalStack is not answering on its gateway" | LocalStack is not running. | Start it with `lstk`. |
| An app emulator you run shows `unreachable`: "nothing is listening at http://127.0.0.1:5101" | The process stopped, or the port is not the one in the variable. | Start it again on that port, or unset the variable to use LocalStack. |
| **Apply for working capital** is disabled | An app emulator is not ready, or the world is not seeded. The page says which. | Fix the app emulator, then press **Reset**. |
| An app emulator shows **Out of date**, and the page says it "no longer holds the seeded world" | The app emulator answers its health check, but it restarted or was reset by something else, so the connected Stripe account, the Plaid access token or the Xero invoices the demo saved are gone. Plaid and Xero keep their data in memory, so a restart loses it. | Press **Reset**. It wipes the app emulators and seeds the world again. |
| `the world is not seeded; run seed or reset first` | The demo started before the world was built, or a reset failed partway. | Press **Reset**, or run `make kit APP=acme-commerce-lenders ARGS=seed`. |
| `the world is already seeded; reset it first (run: reset)` | `seed` was run twice. It never adds to a world, so the second run stops. | Run `make kit APP=acme-commerce-lenders ARGS=reset`. |
| Settling fails with `xero PUT /api.xro/2.0/Accounts: HTTP 405: Method Not Allowed` | The Xero app emulator is older than the change that lets it create accounts and bank transactions. The earlier steps work, so funding succeeds and only the booking fails. | Rebuild `twin-xero` from `WonderTwin-AI/wondertwin` `main`, restart it and press **Reset**. |
Expand Down
7 changes: 6 additions & 1 deletion demos/acme-commerce-lenders/internal/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ func (a WorldAdmin) Status(ctx context.Context) (Status, error) {
case !errors.Is(err, core.ErrNotFound):
return st, err
}
st.Ready = healthy && st.Seeded
// Only a healthy app emulator can be asked what it holds. An unhealthy one is
// already reported, and its saved world may still be there when it is back.
if healthy && st.Seeded {
st.Stale = world.Verify(ctx, a.HC, a.Targets, facts)
}
st.Ready = healthy && st.Seeded && len(st.Stale) == 0
return st, nil
}

Expand Down
5 changes: 4 additions & 1 deletion demos/acme-commerce-lenders/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ type Admin interface {

// Status is what the UI needs to know before it lets a presenter start.
type Status struct {
Ready bool `json:"ready"` // every app emulator is healthy and the world is seeded
Ready bool `json:"ready"` // every app emulator is healthy and holds the seeded world
Seeded bool `json:"seeded"`
Emulators []seed.Status `json:"emulators"`
Facts *world.Facts `json:"facts,omitempty"`
// Stale lists each app emulator that is healthy but no longer holds the
// world that was seeded, such as one that restarted. A reset fixes it.
Stale []world.Stale `json:"stale,omitempty"`
}

// New returns the API handler, to be mounted at /api/.
Expand Down
53 changes: 53 additions & 0 deletions demos/acme-commerce-lenders/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -297,3 +299,54 @@ func TestTargetsReportAndChangeWhichBaseURLEachVendorIsPointedAt(t *testing.T) {
}
}
}

func TestAStaleWorldIsNotReadyUntilItIsReset(t *testing.T) {
// Plaid restarts: it answers health, but the access token saved at seeding
// is gone. The status must not say ready, and a reset must put it right.
var resets atomic.Int32
vendors := fakevendors.New(t, &resets)
var plaidRestarted atomic.Bool
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/item/get" && plaidRestarted.Load() {
http.Error(w, `{"error_code":"INVALID_ACCESS_TOKEN"}`, http.StatusBadRequest)
return
}
if r.URL.Path == "/admin/reset" {
plaidRestarted.Store(false) // a reset gives it a fresh Item
}
httputil.NewSingleHostReverseProxy(mustURL(t, vendors.URL)).ServeHTTP(w, r)
}))
defer front.Close()
tg := world.Targets{Stripe: wire.Direct("stripe", front.URL), Plaid: wire.Direct("plaid", front.URL), Xero: wire.Direct("xero", front.URL)}
admin := WorldAdmin{Targets: tg, Store: core.NewMemStore(), Rec: wire.NewRecorder(10)}
ctx := context.Background()

if _, err := admin.Reset(ctx); err != nil {
t.Fatal(err)
}
if st, err := admin.Status(ctx); err != nil || !st.Ready || len(st.Stale) != 0 {
t.Fatalf("a freshly seeded world is ready: %+v, %v", st, err)
}

plaidRestarted.Store(true)
st, err := admin.Status(ctx)
if err != nil || st.Ready || !st.Seeded || len(st.Stale) == 0 || st.Stale[0].Vendor != "plaid" {
t.Fatalf("a restarted Plaid makes the world stale, not ready: %+v, %v", st, err)
}

if _, err := admin.Reset(ctx); err != nil {
t.Fatal(err)
}
if st, err = admin.Status(ctx); err != nil || !st.Ready || len(st.Stale) != 0 {
t.Fatalf("a reset makes it ready again: %+v, %v", st, err)
}
}

func mustURL(t *testing.T, s string) *url.URL {
t.Helper()
u, err := url.Parse(s)
if err != nil {
t.Fatal(err)
}
return u
}
8 changes: 7 additions & 1 deletion demos/acme-commerce-lenders/world/plaid.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@ type PlaidFacts struct {

const plaidInstitution = "ins_109508"

// The sandbox credentials the lender sends Plaid. The app emulator accepts any.
const (
plaidClientID = "acme-demo-client"
plaidSecret = "acme-demo-secret"
)

// SeedPlaid connects a bank account for the merchant the way a Plaid Link
// session would, through the sandbox. The app emulator's clock is moved to the
// world's date first, so the fixture's transactions fall in the last few days
// of the history.
func SeedPlaid(ctx context.Context, hc *http.Client, t wire.Target) (PlaidFacts, error) {
var facts PlaidFacts
api := seed.API{HC: hc, Target: t}
creds := map[string]any{"client_id": "acme-demo-client", "secret": "acme-demo-secret"}
creds := map[string]any{"client_id": plaidClientID, "secret": plaidSecret}
body := func(extra map[string]any) map[string]any {
m := map[string]any{}
for k, v := range creds {
Expand Down
57 changes: 57 additions & 0 deletions demos/acme-commerce-lenders/world/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package world

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/localstack-samples/localstack-app-emulators/shared/seed"
)

// Stale says one app emulator no longer holds the world the lender saved. It
// happens when an app emulator restarts or is reset by something else: it
// answers its health check, but the Plaid access token, the connected Stripe
// account or the Xero invoices the facts name are gone.
type Stale struct {
Vendor string `json:"vendor"`
Reason string `json:"reason"`
}

// Verify checks that each app emulator still holds what f says seeding put in
// it, and returns one Stale for each that does not.
//
// Only a definite answer counts. An app emulator that cannot be reached, or
// answers with a server error, is not reported here, because the readiness
// check already says so and this one must not call a slow app emulator stale.
func Verify(ctx context.Context, hc *http.Client, tg Targets, f Facts) []Stale {
var out []Stale
add := func(vendor string, err error) {
var e *seed.Error
if errors.As(err, &e) && e.Status >= 400 && e.Status < 500 {
out = append(out, Stale{Vendor: vendor, Reason: fmt.Sprintf("the %s app emulator no longer holds the seeded world (%v)", vendor, err)})
}
}

stripe := seed.API{HC: hc, Target: tg.Stripe}
add("stripe", stripe.Do(ctx, http.MethodGet, "/v1/accounts/"+f.Stripe.AccountID,
map[string]string{"Authorization": "Bearer " + StripeKey}, "", nil, nil))

plaid := seed.API{HC: hc, Target: tg.Plaid}
add("plaid", plaid.JSON(ctx, http.MethodPost, "/item/get", nil, map[string]any{
"client_id": plaidClientID, "secret": plaidSecret, "access_token": f.Plaid.AccessToken,
}, nil))

xero := seed.API{HC: hc, Target: tg.Xero}
var invoices struct {
Invoices []struct{ InvoiceID string }
}
err := xero.Do(ctx, http.MethodGet, "/api.xro/2.0/Invoices",
map[string]string{"Authorization": "Bearer demo", "Xero-Tenant-Id": f.Xero.TenantID}, "", nil, &invoices)
add("xero", err)
if err == nil && len(invoices.Invoices) != f.Xero.Invoices {
out = append(out, Stale{Vendor: "xero", Reason: fmt.Sprintf(
"the xero app emulator holds %d invoices, and seeding made %d", len(invoices.Invoices), f.Xero.Invoices)})
}
return out
}
86 changes: 86 additions & 0 deletions demos/acme-commerce-lenders/world/verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package world

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/localstack-samples/localstack-app-emulators/shared/wire"
)

// verifyRig is three app emulators that each hold the seeded world, and the
// facts that describe it. A test removes what one of them holds.
func verifyRig(t *testing.T) (Targets, Facts, map[string]*fake) {
t.Helper()
fakes := map[string]*fake{
"stripe": newFake(t, map[string]string{"GET /v1/accounts/acct_1": `{"id":"acct_1"}`}),
"plaid": newFake(t, map[string]string{"POST /item/get": `{"item":{}}`}),
"xero": newFake(t, map[string]string{"GET /api.xro/2.0/Invoices": `{"Invoices":[{"InvoiceID":"a"},{"InvoiceID":"b"}]}`}),
}
tg := Targets{
Stripe: wire.Direct("stripe", fakes["stripe"].srv.URL),
Plaid: wire.Direct("plaid", fakes["plaid"].srv.URL),
Xero: wire.Direct("xero", fakes["xero"].srv.URL),
}
f := Facts{
Stripe: StripeFacts{AccountID: "acct_1"},
Plaid: PlaidFacts{AccessToken: "access-sandbox-1"},
Xero: XeroFacts{TenantID: XeroTenantID, Invoices: 2},
}
return tg, f, fakes
}

func TestVerifyFindsNothingWhenEveryAppEmulatorHoldsTheWorld(t *testing.T) {
tg, f, _ := verifyRig(t)
if got := Verify(context.Background(), nil, tg, f); len(got) != 0 {
t.Fatalf("want a world that checks out, got %+v", got)
}
}

func TestVerifyReportsAPlaidTokenTheAppEmulatorNoLongerKnows(t *testing.T) {
// The case that showed "Ready" over a broken world: Plaid restarted, so the
// access token saved at seeding is unknown to it.
tg, f, fakes := verifyRig(t)
delete(fakes["plaid"].routes, "POST /item/get")

got := Verify(context.Background(), nil, tg, f)
if len(got) != 1 || got[0].Vendor != "plaid" {
t.Fatalf("want plaid reported, got %+v", got)
}
if calls := fakes["plaid"].to("POST", "/item/get"); len(calls) != 1 || !strings.Contains(calls[0].Body, "access-sandbox-1") {
t.Fatalf("want one /item/get call carrying the saved token, got %+v", calls)
}
}

func TestVerifyReportsAMissingStripeAccountAndShortXeroInvoices(t *testing.T) {
tg, f, fakes := verifyRig(t)
delete(fakes["stripe"].routes, "GET /v1/accounts/acct_1")
fakes["xero"].routes["GET /api.xro/2.0/Invoices"] = `{"Invoices":[{"InvoiceID":"a"}]}`

got := Verify(context.Background(), nil, tg, f)
vendors := []string{}
for _, s := range got {
vendors = append(vendors, s.Vendor)
}
if strings.Join(vendors, ",") != "stripe,xero" {
t.Fatalf("want stripe and xero reported, got %+v", got)
}
}

func TestVerifyDoesNotCallASlowOrDownAppEmulatorStale(t *testing.T) {
// A server error or a refused connection says nothing about what the app
// emulator holds, and the readiness check already reports it.
tg, f, fakes := verifyRig(t)
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer broken.Close()
tg.Stripe = wire.Direct("stripe", broken.URL)
fakes["plaid"].srv.Close()

if got := Verify(context.Background(), nil, tg, f); len(got) != 0 {
t.Fatalf("want nothing reported for a 500 or a refused connection, got %+v", got)
}
}
5 changes: 5 additions & 0 deletions web/demos/acme-commerce-lenders/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export interface Status {
seeded: boolean
emulators: EmulatorStatus[]
facts?: Facts
/**
* Each app emulator that is healthy but no longer holds the seeded world,
* such as one that restarted. Reset puts it right.
*/
stale?: { vendor: string; reason: string }[]
}

export interface WireBody {
Expand Down
21 changes: 17 additions & 4 deletions web/demos/acme-commerce-lenders/src/components/readiness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const sourceNames: Record<EmulatorStatus['source'], string> = {
local: 'Local',
}

function Pill({ e }: { e: EmulatorStatus }) {
const ok = e.state === 'healthy'
function Pill({ e, stale }: { e: EmulatorStatus; stale: boolean }) {
const ok = e.state === 'healthy' && !stale
return (
<div
className="flex items-center justify-between gap-3 rounded-lg border px-4 py-3"
Expand All @@ -29,7 +29,7 @@ function Pill({ e }: { e: EmulatorStatus }) {
</div>
</div>
<Badge variant={ok ? 'success' : 'destructive'}>
{ok ? 'Ready' : e.state}
{ok ? 'Ready' : stale ? 'Out of date' : e.state}
</Badge>
</div>
)
Expand Down Expand Up @@ -64,14 +64,27 @@ export function Readiness() {
<Card>
<div className="grid grid-cols-3 gap-3">
{data.emulators.map((e) => (
<Pill key={e.vendor} e={e} />
<Pill
key={e.vendor}
e={e}
stale={data.stale?.some((s) => s.vendor === e.vendor) ?? false}
/>
))}
</div>
{problems.map((e) => (
<p key={e.vendor} className="text-sm text-destructive" role="alert">
{vendorNames[e.vendor] ?? e.vendor}: {e.hint ?? e.state}
</p>
))}
{problems.length === 0 && (data.stale?.length ?? 0) > 0 && (
<p className="text-sm text-destructive" role="alert">
{data.stale
?.map((s) => vendorNames[s.vendor] ?? s.vendor)
.join(' and ')}{' '}
no longer holds the seeded world, as if it restarted. Press Reset to
seed it again.
</p>
)}
{problems.length === 0 && !data.seeded && (
<p className="text-sm text-muted-foreground" role="status">
The world is not seeded yet. Press Reset to seed it.
Expand Down
24 changes: 24 additions & 0 deletions web/demos/acme-commerce-lenders/src/demo/demo-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,30 @@ describe('DemoPage', () => {
expect(await screen.findByRole('status')).toHaveTextContent('Press Reset')
})

it('says what is out of date, and holds Apply back, when an app emulator lost the seeded world', async () => {
mocks.getStatus.mockResolvedValue({
...readyStatus,
ready: false,
stale: [
{
vendor: 'plaid',
reason: 'the plaid app emulator no longer holds the seeded world',
},
],
})
renderPage()
expect(await screen.findByTestId('emulator-plaid')).toHaveTextContent(
'Out of date',
)
expect(screen.getByRole('alert')).toHaveTextContent(
'Plaid no longer holds the seeded world',
)
expect(screen.getByRole('alert')).toHaveTextContent('Press Reset')
expect(
screen.getByRole('button', { name: /apply for working capital/i }),
).toBeDisabled()
})

it('runs apply, connect, review and offer on the first click, in order', async () => {
const user = userEvent.setup()
renderPage()
Expand Down
4 changes: 3 additions & 1 deletion web/demos/acme-commerce-lenders/src/demo/demo-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ export function DemoPage() {
<span className="text-sm text-muted-foreground">
{badUrl
? 'Fix the base URL above to run the demo.'
: 'Waiting for the app emulators and the seeded world.'}
: status.data?.stale?.length
? 'The seeded world is out of date. Press Reset.'
: 'Waiting for the app emulators and the seeded world.'}
</span>
)}
</div>
Expand Down
Loading