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
5 changes: 4 additions & 1 deletion .github/workflows/server-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ jobs:
NODE_ENV: test
DOTENV_CONFIG_PATH: .env.test
NODE_OPTIONS: --require=dotenv/config
run: pnpm test -- --run --json --outputFile=test-results.json
# `--run` is a Vitest flag; Jest 29 aborts with "Unrecognized option
# \"run\"" before running a single suite, so no test-results.json was
# written and the JUnit conversion below failed on the missing file.
run: pnpm test -- --json --outputFile=test-results.json

- name: Convert Jest results to JUnit XML
if: always()
Expand Down
59 changes: 59 additions & 0 deletions frontend/app/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* PWA manifest screenshots (issue #293).
*
* Chrome's Android install sheet only shows previews when the manifest has a
* `screenshots` array whose entries are tagged with a `form_factor`, so the
* assertions below pin both the metadata and the actual files on disk — a
* manifest pointing at a missing or wrong-sized image silently degrades to
* the minimal install banner.
*/

import fs from "fs";
import path from "path";

import manifest from "./manifest";

const PUBLIC_DIR = path.join(process.cwd(), "public");

/** Reads the width/height out of a PNG's IHDR chunk. */
function pngSize(file: string): { width: number; height: number } {
const buf = fs.readFileSync(file);
// 8-byte signature, then a length (4) + "IHDR" (4), then width/height.
expect(buf.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
expect(buf.subarray(12, 16).toString("ascii")).toBe("IHDR");
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}

describe("manifest screenshots", () => {
const screenshots = manifest().screenshots ?? [];

it("declares at least two screenshots", () => {
expect(screenshots.length).toBeGreaterThanOrEqual(2);
});

it("tags every screenshot as a narrow (mobile) viewport", () => {
for (const shot of screenshots) {
expect(shot).toMatchObject({ form_factor: "narrow" });
}
});

it("describes each screenshot as a portrait mobile image", () => {
for (const shot of screenshots) {
expect(shot.type).toBe("image/png");
expect(shot.sizes).toBe("1080x1920");
expect(shot.src).toMatch(/^\/screenshots\/.+\.png$/);
}
});

it.each((manifest().screenshots ?? []).map((s) => [s.src, s] as const))(
"%s exists on disk at the declared size",
(src, shot) => {
const file = path.join(PUBLIC_DIR, src.replace(/^\//, ""));
expect(fs.existsSync(file)).toBe(true);

const { width, height } = pngSize(file);
const [w, h] = (shot.sizes ?? "").split("x").map(Number);
expect({ width, height }).toEqual({ width: w, height: h });
}
);
});
37 changes: 37 additions & 0 deletions frontend/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
import type { MetadataRoute } from "next";
import { SITE_NAME, SITE_DESCRIPTION } from "./lib/seo";

/**
* Screenshot descriptor accepted by the W3C Web App Manifest spec.
*
* Next's `MetadataRoute.Manifest` types only cover `src`/`sizes`/`type`, but
* Chrome's richer install prompt keys off `form_factor` (and the `label` that
* goes with it). Extending the entry type keeps the manifest fully typed while
* still being assignable to Next's narrower shape.
*/
type Screenshot = NonNullable<MetadataRoute.Manifest["screenshots"]>[number] & {
form_factor?: "narrow" | "wide";
label?: string;
};

/**
* Phone-sized previews shown in Chrome's "Add to Home Screen" sheet
* (issue #293). Both are 1080x1920 (9:16), the portrait viewport the app
* targets, and both are tagged `form_factor: "narrow"` so Chrome treats them
* as mobile screenshots in the richer install prompt.
*/
const SCREENSHOTS: Screenshot[] = [
{
src: "/screenshots/signup-mobile.png",
sizes: "1080x1920",
type: "image/png",
form_factor: "narrow",
label: "Create an account with your phone number",
},
{
src: "/screenshots/sell-mobile.png",
sizes: "1080x1920",
type: "image/png",
form_factor: "narrow",
label: "Create a listing and set your own rate",
},
];

/**
* Web App Manifest (issue #107).
*
Expand Down Expand Up @@ -40,5 +76,6 @@ export default function manifest(): MetadataRoute.Manifest {
purpose: "maskable",
},
],
screenshots: SCREENSHOTS,
};
}
4 changes: 4 additions & 0 deletions frontend/app/wallet/WithdrawModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@ export default function WithdrawModal({
{/* Submit button */}
<button
type="submit"
// Explicit label while submitting: the spinner SVG carries
// aria-label="processing", and without this the button's accessible
// name would be read twice ("processing processing").
aria-label={isSubmitting ? t("processing") : undefined}
disabled={
isSubmitting ||
!accountConfirmed ||
Expand Down
18 changes: 12 additions & 6 deletions frontend/app/wallet/depositCopy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,21 @@ describe("CopyAccountNumberButton", () => {
});

it("confirms the copy for two seconds, then reverts", async () => {
// Fake timers must be installed *before* the click: the reset timeout is
// scheduled when the copy resolves, and a timeout created under real timers
// cannot be advanced by the fake clock afterwards.
jest.useFakeTimers();

render(<CopyAccountNumberButton accountNumber={ACCOUNT_NUMBER} />);
expect(copyButton()).toHaveTextContent("Copy");

fireEvent.click(copyButton());
await screen.findByText("Copied!");

// Switch to fake timers only once the async copy has settled, so the
// promise above is not waiting on a clock that no longer advances.
jest.useFakeTimers();
await act(async () => {
fireEvent.click(copyButton());
// Flush the mocked clipboard promise so the reset timer is scheduled.
await Promise.resolve();
await Promise.resolve();
});
expect(copyButton()).toHaveTextContent("Copied!");

act(() => {
jest.advanceTimersByTime(COPIED_FEEDBACK_MS - 1);
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/wallet/withdraw.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ describe("WithdrawModal Available Balance and Max button", () => {

fireEvent.click(maxButton);

const amountInput = screen.getByLabelText(/amount/i) as HTMLInputElement;
// Exact match: /amount/i would also match the "Max amount" button's
// aria-label, making the query ambiguous.
const amountInput = screen.getByLabelText("amount") as HTMLInputElement;
expect(amountInput.value).toBe("10,000");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`EscrowTransactionLink matches the snapshot for a sample hash 1`] = `
<a
class="inline-flex items-center gap-1 text-violet-600 hover:text-violet-700 hover:underline dark:text-violet-400 dark:hover:text-violet-300 font-mono text-xs "
href="https://stellar.expert/explorer/testnet/tx/3389e9f0f1a54f7b1d5b2a0c9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c"
rel="noopener noreferrer"
target="_blank"
title="View transaction on Stellar Expert"
>
<span>
View escrow on Stellar Explorer
</span>
<svg
aria-hidden="true"
class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity hover:opacity-100"
data-testid="external-link-icon"
fill="none"
role="img"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</a>
`;
Binary file added frontend/public/screenshots/sell-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/screenshots/signup-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 45 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions server/src/routes/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ describe("GET /api/v1/profile", () => {
});

it("returns 200 with profile data and non-empty referralCode", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

// 1st query: users
mockQuery.mockResolvedValueOnce({
rows: [
Expand Down Expand Up @@ -74,6 +77,10 @@ describe("GET /api/v1/profile", () => {
});

it("returns 404 when user is not found in database", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

// 1st query: users (not found)
mockQuery.mockResolvedValueOnce({ rows: [] });

const res = await request(app)
Expand Down
5 changes: 4 additions & 1 deletion server/src/routes/trades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,16 @@ describe("POST /api/v1/trades/:id/rate — issue #362: ratings survive account a

// -------------------------------------------------------------------------
// Helper: wire up mockQuery for a successful rating flow
// The mock receives calls in this order from the route handler:
// The mock receives calls in this order:
// 0. SELECT token_version FROM users ... → authenticate() revocation check
// 1. SELECT trade_offers WHERE id = $1 → completed trade row
// 2. SELECT phone FROM users WHERE id = $1 → seller's phone / anon hash
// 3. INSERT INTO ratings ... → new rating row
// -------------------------------------------------------------------------
function wireSuccessfulRating(sellerPhone: string) {
mockQuery
// call 0 — authenticate() token-revocation check
.mockResolvedValueOnce({ rows: [{ token_version: 1 }] })
// call 1 — load the trade
.mockResolvedValueOnce({
rows: [
Expand Down
5 changes: 4 additions & 1 deletion server/src/schemas/ratings.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { z } from "zod";

export const createRatingSchema = z.object({
stars: z.number().int().min(1).max(5),
comment: z.string().max(300).optional(),
// `nullish` (not just optional): clients commonly serialise "no comment" as
// an explicit null in JSON, and the route persists `comment ?? null` either
// way, so an explicit null must not be rejected as a validation error.
comment: z.string().max(300).nullish(),
});

export type CreateRatingInput = z.infer<typeof createRatingSchema>;
Expand Down
Loading