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 .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Closes #
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] Updated Postman/Hoppscotch API collections if routes changed
- [ ] If routes or response shapes changed, ran the OpenAPI api-types drift check (`npm run codegen:openapi` in `backend/`, then `npm run codegen:api-types` in `frontend/`) and committed the regenerated `flowfi.openapi.json` / `api-types.generated.ts`
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
Expand Down
40 changes: 38 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,31 @@ This repository uses GitHub Actions for continuous integration. Workflows are lo
- **CI** (`.github/workflows/ci.yml`)
- Runs on: push to `main`/`develop` and pull requests
- Performs:
- Frontend: lint and build
- Backend: prisma generation, build, and tests
- Frontend: lint, tests, and build
- Backend: prisma generation, build, OpenAPI drift check, and tests
- Soroban Contracts: build (wasm) and tests
- View workflow: [CI](.github/workflows/ci.yml)

### API Types Drift Check

The frontend's hand-written contract types in
`frontend/src/lib/api-types.ts` are mirrored by a machine-generated snapshot,
`frontend/src/lib/api-types.generated.ts`. CI keeps the two in sync so a
frontend build can never silently disagree with the backend's real response
shape:

1. The backend exports the committed spec: `cd backend && npm run codegen:openapi`
(writes `backend/swagger/flowfi.openapi.json`).
2. The frontend regenerates the types from that spec:
`cd frontend && npm run codegen:api-types`.
3. CI runs `git diff --exit-code` over both artifacts. Any uncommitted change
means the spec or the generated types drifted and the job fails.

When you change a backend response shape or route annotation, run both commands
above and commit the updated `flowfi.openapi.json` and `api-types.generated.ts`
alongside your change. If a change would alter the shape consumed by the
frontend, update `api-types.ts` and its call sites by hand too.

### Running CI Checks Locally

Before pushing, ensure your changes pass all the same checks that run in GitHub Actions.
Expand All @@ -312,6 +332,14 @@ npm run test # Runs backend vitest suite
```
*Note: Backend tests require a running PostgreSQL instance and `DATABASE_URL` environment variable.*

Response-shape changes must also pass the OpenAPI drift check:

```bash
cd backend && npm run codegen:openapi
cd ../frontend && npm run codegen:api-types
cd .. && git diff --exit-code -- backend/swagger/flowfi.openapi.json frontend/src/lib/api-types.generated.ts
```

#### 3. Smart Contract Checks
```bash
cd contracts
Expand Down Expand Up @@ -446,6 +474,14 @@ Your PR must:
- Be properly described
- Stay focused (avoid large unrelated changes)

**PR checklist**

- [ ] Frontend lint, tests, and build pass (`cd frontend && npm run lint && npm run test && npm run build`)
- [ ] Backend build and tests pass (`cd backend && npm run build && npm run test`)
- [ ] Contract build, `cargo fmt`, `cargo clippy`, and tests pass (`cd contracts`)
- [ ] If you touched a backend response shape or route annotations, the OpenAPI drift check is clean (`npm run codegen:openapi` in `backend/`, then `npm run codegen:api-types` in `frontend/`, then `git diff --exit-code`) and the regenerated `flowfi.openapi.json` / `api-types.generated.ts` are committed
- [ ] Related issues are linked with `Closes #NNN`

---

## Code Review
Expand Down
78 changes: 78 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,48 @@ fn test_create_stream_zero_rate() {
assert_stream_error!(result, StreamError::InvalidRate);
}

// Issue #1542: a rejected `create_stream` must roll back *everything* it did —
// the gross transfer from the sender, any fee it computed for the treasury, and
// the stream-id counter bump — not merely return `InvalidRate`.
#[test]
fn test_create_stream_invalid_rate_rolls_back_transfer_fee_and_counter() {
let env = Env::default();
env.mock_all_auths();
let (token, _) = create_token(&env);
let sender = Address::generate(&env);
let recipient = Address::generate(&env);
let admin = Address::generate(&env);
let treasury = Address::generate(&env);
mint(&env, &token, &sender, 100);

let client = create_contract(&env);
// A 1% protocol fee makes the gross (100) and net (99) diverge: 99 / 1_000
// rounds to a 0/s rate, which `create_stream` rejects as `InvalidRate`.
client.initialize(&admin, &treasury, &100);

let token_client = token::Client::new(&env, &token);
let sender_before = token_client.balance(&sender);
let treasury_before = token_client.balance(&treasury);
let contract_before = token_client.balance(&client.address);

let result = client.try_create_stream(&sender, &recipient, &token, &100, &1_000);
assert_stream_error!(result, StreamError::InvalidRate);

// The sender's gross transfer to the contract is unwound and no fee was
// retained by the contract or paid to the treasury.
assert_eq!(token_client.balance(&sender), sender_before);
assert_eq!(token_client.balance(&treasury), treasury_before);
assert_eq!(token_client.balance(&client.address), contract_before);

// `next_stream_id` bumps before the rate guard, so the failed attempt must
// not consume an ID: the next successful stream is still stream #1.
let stream_id = client.create_stream(&sender, &recipient, &token, &100, &10);
assert_eq!(stream_id, 1);
assert!(client.get_stream(&stream_id).is_some());
// The successful create did charge the fee, proving the fee machinery ran.
assert_eq!(token_client.balance(&treasury), treasury_before + 1);
}

#[test]
fn test_create_stream_rate_exactly_one_succeeds() {
// amount == duration → rate = 1, which is the smallest valid rate.
Expand Down Expand Up @@ -2019,6 +2061,42 @@ fn test_is_stream_completed_helper() {
assert!(client.is_stream_completed(&id));
}

// ─── Missing-state read-only queries (#1541) ─────────────────────────────────

#[test]
fn test_get_stream_missing_id_returns_none() {
let env = Env::default();
let client = create_contract(&env);

// A never-created ID must read as `None` rather than a `StreamNotFound`
// error, exercising `try_load_stream`'s absent-key branch.
assert!(client.get_stream(&0).is_none());
assert!(client.get_stream(&1).is_none());
assert!(client.get_stream(&u64::MAX).is_none());
}

#[test]
fn test_get_fee_config_before_initialize_returns_none() {
let env = Env::default();
let client = create_contract(&env);

// `initialize` has never been called, so `try_load_config` has no record to
// decode and `get_fee_config` must report the absence as `None`.
assert!(client.get_fee_config().is_none());
}

#[test]
fn test_is_stream_completed_missing_id_returns_false() {
let env = Env::default();
let client = create_contract(&env);

// A never-created stream is not "completed"; the missing branch of
// `try_load_stream` must map to `false` rather than panic.
assert!(!client.is_stream_completed(&0));
assert!(!client.is_stream_completed(&1));
assert!(!client.is_stream_completed(&u64::MAX));
}

#[test]
fn test_completed_event_emitted_on_final_withdrawal() {
let env = Env::default();
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/components/ui/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Button } from "./Button";

describe("Button", () => {
it("renders its children", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: "Click me" })
).toBeInTheDocument();
});

it("applies the primary/md styling by default", () => {
render(<Button>Default</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-accent");
expect(button.className).toContain("px-6");
expect(button.className).toContain("text-sm");
});

it.each([
["secondary", "bg-accent-secondary"],
["outline", "bg-glass"],
["ghost", "text-slate-400"],
] as const)("applies the %s variant classes", (variant, expectedClass) => {
render(<Button variant={variant}>Variant</Button>);
expect(screen.getByRole("button").className).toContain(expectedClass);
});

it.each([
["sm", "px-4"],
["md", "px-6"],
["lg", "px-8"],
] as const)("applies the %s size classes", (size, expectedClass) => {
render(<Button size={size}>Size</Button>);
expect(screen.getByRole("button").className).toContain(expectedClass);
});

it("applies the glow shadow classes when glow is set", () => {
render(<Button glow>Glow</Button>);
expect(screen.getByRole("button").className).toContain(
"shadow-[0_0_20px_rgba(16,185,129,0.3)]"
);
});

it("is disabled when disabled is true", () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});

it("is disabled, aria-busy, and announces Loading while loading", () => {
render(<Button loading>Loading...</Button>);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "true");
expect(screen.getByText("Loading")).toBeInTheDocument();
});

it("is idle and not busy when not loading", () => {
render(<Button>Idle</Button>);
const button = screen.getByRole("button");
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "false");
expect(screen.queryByText("Loading")).not.toBeInTheDocument();
});
});
57 changes: 57 additions & 0 deletions frontend/src/components/ui/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Card } from "./Card";

describe("Card", () => {
it("renders its children", () => {
render(
<Card>
<span>Inner content</span>
</Card>
);
expect(screen.getByText("Inner content")).toBeInTheDocument();
});

it("applies the base card styling", () => {
const { container } = render(<Card>Base</Card>);
const el = container.firstElementChild as HTMLElement;
expect(el.className).toContain("glass-card");
expect(el.className).toContain("rounded-2xl");
expect(el.className).toContain("p-6");
});

it("adds the glow class when glow is set", () => {
const { container } = render(<Card glow>Glowing</Card>);
expect((container.firstElementChild as HTMLElement).className).toContain(
"glow-card"
);
});

it("omits the glow class by default", () => {
const { container } = render(<Card>Plain</Card>);
expect((container.firstElementChild as HTMLElement).className).not.toContain(
"glow-card"
);
});

it("applies hover styling by default", () => {
const { container } = render(<Card>Hover</Card>);
expect((container.firstElementChild as HTMLElement).className).toContain(
"hover:-translate-y-1"
);
});

it("omits hover styling when hover is false", () => {
const { container } = render(<Card hover={false}>Static</Card>);
expect((container.firstElementChild as HTMLElement).className).not.toContain(
"hover:-translate-y-1"
);
});

it("merges a custom className", () => {
const { container } = render(<Card className="custom-class">Custom</Card>);
const el = container.firstElementChild as HTMLElement;
expect(el.className).toContain("glass-card");
expect(el.className).toContain("custom-class");
});
});
80 changes: 80 additions & 0 deletions frontend/src/components/ui/Stepper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Stepper } from "./Stepper";

const steps = ["Connect", "Configure", "Confirm"];

/** Class names of every element matching `selector`, in document order. */
function allClasses(container: HTMLElement, selector: string): string[] {
return Array.from(container.querySelectorAll<HTMLElement>(selector)).map(
(el) => el.className
);
}

/** The `index`-th match for `selector`, failing loudly if it is missing. */
function nth(container: HTMLElement, selector: string, index: number): HTMLElement {
const el = container.querySelectorAll<HTMLElement>(selector)[index];
if (!el) {
throw new Error(`expected at least ${index + 1} match(es) for "${selector}"`);
}
return el;
}

describe("Stepper", () => {
it("renders every step inside an accessible Progress landmark", () => {
render(<Stepper steps={steps} currentStep={1} />);
expect(
screen.getByRole("navigation", { name: "Progress" })
).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(3);
expect(screen.getByText("Connect")).toBeInTheDocument();
expect(screen.getByText("Configure")).toBeInTheDocument();
expect(screen.getByText("Confirm")).toBeInTheDocument();
});

it("marks only the active step with aria-current=step", () => {
render(<Stepper steps={steps} currentStep={2} />);
const items = screen.getAllByRole("listitem");
expect(items[0]).not.toHaveAttribute("aria-current");
expect(items[1]).toHaveAttribute("aria-current", "step");
expect(items[2]).not.toHaveAttribute("aria-current");
});

it("applies completed/active/upcoming classes per step index", () => {
const { container } = render(<Stepper steps={steps} currentStep={2} />);

expect(allClasses(container, ".stepper__circle")).toEqual([
expect.stringContaining("stepper__circle--completed"),
expect.stringContaining("stepper__circle--active"),
expect.stringContaining("stepper__circle--upcoming"),
]);
expect(allClasses(container, ".stepper__label")).toEqual([
expect.stringContaining("stepper__label--completed"),
expect.stringContaining("stepper__label--active"),
expect.stringContaining("stepper__label--upcoming"),
]);
});

it("adds the matching screen-reader suffix to each step", () => {
render(<Stepper steps={steps} currentStep={2} />);
expect(screen.getByText("(completed)")).toBeInTheDocument();
expect(screen.getByText("(current step)")).toBeInTheDocument();
expect(screen.getByText("(upcoming)")).toBeInTheDocument();
});

it("shows a check icon for completed steps and a number otherwise", () => {
const { container } = render(<Stepper steps={steps} currentStep={2} />);

expect(nth(container, ".stepper__circle", 0).querySelector("svg")).not.toBeNull();
expect(nth(container, ".stepper__circle", 1).querySelector("svg")).toBeNull();
expect(nth(container, ".stepper__circle", 2).querySelector("svg")).toBeNull();
expect(
nth(container, ".stepper__circle", 1).querySelector(".stepper__number")
?.textContent
).toBe("2");
expect(
nth(container, ".stepper__circle", 2).querySelector(".stepper__number")
?.textContent
).toBe("3");
});
});
21 changes: 14 additions & 7 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
* backend/src/config/swagger.ts (JSDoc annotations on the route files feed
* swagger-jsdoc, which builds the spec served at /api-docs.json).
*
* An opt-in codegen script exists to help cross-check this file against the
* live spec without replacing it automatically:
* `npm run codegen:api-types` (in frontend/) runs openapi-typescript
* against a locally running backend's /api-docs.json and writes the result
* to src/lib/api-types.generated.ts. It is NOT wired into CI and does NOT
* replace these hand-written types/call sites — run it manually, diff the
* output against this file, and update this file by hand when they drift.
* The checked-in `api-types.generated.ts` is produced from the committed spec
* by `npm run codegen:api-types` (in frontend/), which runs openapi-typescript
* against backend/swagger/flowfi.openapi.json. That generated file is the
* machine-readable mirror of this hand-written contract and is enforced in CI:
* the backend job re-exports the OpenAPI spec and regenerates the types, then
* `git diff --exit-code` fails the build if either drifts. When this file
* changes, update the backend spec/annotations and regenerate with:
*
* cd backend && npm run codegen:openapi
* cd ../frontend && npm run codegen:api-types
*
* See the "API Types Drift Check" section of CONTRIBUTING.md. This file is
* still maintained by hand because the generated output does not replace these
* narrow, call-site-oriented interfaces.
*/
export interface BackendUser {
id: string;
Expand Down
Loading