@ramonclaudio/create-vexpo 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/index.js +8 -7
- package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
- package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
- package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
- package/dist/templates/default/.eas/workflows/release.yml +3 -7
- package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
- package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
- package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
- package/dist/templates/default/.github/workflows/check.yml +20 -12
- package/dist/templates/default/.maestro/launch.yaml +19 -10
- package/dist/templates/default/AGENTS.md +25 -8
- package/dist/templates/default/DESIGN.md +14 -10
- package/dist/templates/default/README.md +83 -78
- package/dist/templates/default/SETUP.md +159 -152
- package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
- package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
- package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
- package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
- package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
- package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
- package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
- package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
- package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
- package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
- package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
- package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
- package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
- package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
- package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
- package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
- package/dist/templates/default/_env.example +7 -7
- package/dist/templates/default/_gitattributes +1 -1
- package/dist/templates/default/_gitignore +17 -2
- package/dist/templates/default/_npmrc +7 -0
- package/dist/templates/default/_oxlintrc.json +1 -1
- package/dist/templates/default/app-store/accessibility.config.json +20 -0
- package/dist/templates/default/app-store/privacy.config.json +27 -0
- package/dist/templates/default/app.config.ts +105 -33
- package/dist/templates/default/app.json +1 -9
- package/dist/templates/default/convex/_generated/api.d.ts +12 -0
- package/dist/templates/default/convex/admin.ts +0 -13
- package/dist/templates/default/convex/appAttest.ts +467 -0
- package/dist/templates/default/convex/appAttestStore.ts +141 -0
- package/dist/templates/default/convex/apple.ts +53 -0
- package/dist/templates/default/convex/auth.ts +6 -45
- package/dist/templates/default/convex/constants.ts +2 -7
- package/dist/templates/default/convex/crons.ts +12 -5
- package/dist/templates/default/convex/email.ts +4 -24
- package/dist/templates/default/convex/env.ts +0 -4
- package/dist/templates/default/convex/errors.ts +0 -7
- package/dist/templates/default/convex/functions.ts +0 -26
- package/dist/templates/default/convex/http.ts +3 -5
- package/dist/templates/default/convex/log.ts +2 -25
- package/dist/templates/default/convex/pushSender.ts +145 -0
- package/dist/templates/default/convex/pushTokens.ts +110 -13
- package/dist/templates/default/convex/rateLimit.ts +8 -39
- package/dist/templates/default/convex/schema.ts +48 -5
- package/dist/templates/default/convex/tsconfig.json +1 -0
- package/dist/templates/default/convex/users.ts +143 -61
- package/dist/templates/default/convex/validators.ts +1 -38
- package/dist/templates/default/convex/webhook.ts +1 -31
- package/dist/templates/default/convex.json +1 -2
- package/dist/templates/default/metro.config.js +9 -1
- package/dist/templates/default/package.json +67 -70
- package/dist/templates/default/plugins/README.md +5 -1
- package/dist/templates/default/scripts/README.md +9 -9
- package/dist/templates/default/scripts/_run.mjs +3 -20
- package/dist/templates/default/scripts/clean.ts +81 -69
- package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
- package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
- package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
- package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
- package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
- package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
- package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
- package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
- package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
- package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
- package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
- package/dist/templates/default/src/app/+native-intent.tsx +25 -0
- package/dist/templates/default/src/app/+not-found.tsx +43 -0
- package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
- package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
- package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
- package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
- package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
- package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
- package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
- package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
- package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
- package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
- package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
- package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
- package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
- package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
- package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
- package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
- package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
- package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
- package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
- package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
- package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
- package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
- package/dist/templates/default/src/hooks/use-network.ts +34 -0
- package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
- package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
- package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
- package/dist/templates/default/src/lib/appAttest.ts +78 -0
- package/dist/templates/default/src/lib/assets.ts +9 -0
- package/dist/templates/default/src/lib/deep-link.ts +82 -0
- package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
- package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
- package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
- package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
- package/dist/templates/default/src/lib/masks.ts +21 -0
- package/dist/templates/default/src/lib/native-state.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
- package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
- package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
- package/dist/templates/default/src/lib/text-style.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
- package/dist/templates/default/store.config.json +1 -1
- package/dist/templates/default/tsconfig.json +3 -1
- package/dist/templates/default/vitest.config.ts +8 -1
- package/package.json +5 -5
- package/dist/templates/default/app/(app)/_layout.tsx +0 -73
- package/dist/templates/default/app/(app)/debug.tsx +0 -389
- package/dist/templates/default/app/(app)/sessions.tsx +0 -191
- package/dist/templates/default/app/(app)/welcome.tsx +0 -140
- package/dist/templates/default/app/+native-intent.tsx +0 -14
- package/dist/templates/default/app/+not-found.tsx +0 -51
- package/dist/templates/default/bun.lock +0 -1860
- package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
- package/dist/templates/default/components/ui/convex-error.tsx +0 -32
- package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
- package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
- package/dist/templates/default/components/ui/status-text.tsx +0 -49
- package/dist/templates/default/components/ui/update-banner.tsx +0 -82
- package/dist/templates/default/fingerprint.config.js +0 -9
- package/dist/templates/default/hooks/use-debounce.ts +0 -20
- package/dist/templates/default/hooks/use-deep-link.ts +0 -43
- package/dist/templates/default/hooks/use-network.ts +0 -11
- package/dist/templates/default/lib/assets.ts +0 -17
- package/dist/templates/default/lib/deep-link.ts +0 -71
- package/dist/templates/default/patches/PR-368.patch +0 -91
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
- /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for `users.restoreAccount` (authMutation).
|
|
4
|
+
*
|
|
5
|
+
* restoreAccount lifts a pending soft-delete: when the authed user's app row
|
|
6
|
+
* carries a `deletedAt` tombstone, it clears `deletedAt`, bumps `updatedAt`,
|
|
7
|
+
* and writes an `accountDeletionAudit` row with event "restored". When the
|
|
8
|
+
* row has no tombstone it's a no-op (returns success, writes nothing).
|
|
9
|
+
*
|
|
10
|
+
* The auth harness mirrors __tests__/convex/_auth-harness.test.ts: seed a
|
|
11
|
+
* Better Auth `user` + unexpired `session` in the component db, capture their
|
|
12
|
+
* REAL component ids, seed the mirrored app `users` row keyed by authId, then
|
|
13
|
+
* drive the mutation with a matching identity. restoreAccount rate-limits via
|
|
14
|
+
* the `criticalAction` bucket, so the rateLimiter component must be registered.
|
|
15
|
+
*/
|
|
16
|
+
import { ConvexError } from "convex/values";
|
|
17
|
+
import { describe, expect, test } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { api } from "@/convex/_generated/api";
|
|
20
|
+
|
|
21
|
+
import { auditRowsFor, identityFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
22
|
+
|
|
23
|
+
describe("users.restoreAccount", () => {
|
|
24
|
+
test("clears deletedAt and writes a 'restored' audit row for a tombstoned user", async () => {
|
|
25
|
+
const t = initConvexTest();
|
|
26
|
+
const tombstonedAt = Date.now() - 60_000;
|
|
27
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t, {
|
|
28
|
+
deletedAt: tombstonedAt,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Precondition: row really is tombstoned before we restore.
|
|
32
|
+
const before = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
33
|
+
expect(before?.deletedAt).toBe(tombstonedAt);
|
|
34
|
+
|
|
35
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
36
|
+
const result = await asUser.mutation(api.users.restoreAccount, {});
|
|
37
|
+
expect(result).toEqual({ success: true });
|
|
38
|
+
|
|
39
|
+
// Real DB effect: the tombstone is gone and updatedAt advanced past it.
|
|
40
|
+
const after = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
41
|
+
expect(after).not.toBeNull();
|
|
42
|
+
expect(after!.deletedAt).toBeUndefined();
|
|
43
|
+
expect(after!.updatedAt).toBeGreaterThan(tombstonedAt);
|
|
44
|
+
|
|
45
|
+
// Exactly one audit row, event "restored", keyed to this user.
|
|
46
|
+
const audit = await auditRowsFor(t, appUserId);
|
|
47
|
+
expect(audit).toHaveLength(1);
|
|
48
|
+
expect(audit[0]!.event).toBe("restored");
|
|
49
|
+
expect(audit[0]!.userId).toBe(appUserId);
|
|
50
|
+
expect(audit[0]!.authId).toBe(authUserId);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no-op for a user without a tombstone: no audit row, row untouched", async () => {
|
|
54
|
+
const t = initConvexTest();
|
|
55
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t); // no deletedAt
|
|
56
|
+
|
|
57
|
+
const beforeUpdatedAt = (await t.run(async (ctx) => ctx.db.get(appUserId)))!.updatedAt;
|
|
58
|
+
|
|
59
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
60
|
+
const result = await asUser.mutation(api.users.restoreAccount, {});
|
|
61
|
+
expect(result).toEqual({ success: true });
|
|
62
|
+
|
|
63
|
+
// Early return: updatedAt is NOT bumped and no audit row is written.
|
|
64
|
+
const after = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
65
|
+
expect(after!.deletedAt).toBeUndefined();
|
|
66
|
+
expect(after!.updatedAt).toBe(beforeUpdatedAt);
|
|
67
|
+
|
|
68
|
+
const audit = await auditRowsFor(t, appUserId);
|
|
69
|
+
expect(audit).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("throws ConvexError when unauthenticated (no identity)", async () => {
|
|
73
|
+
const t = initConvexTest();
|
|
74
|
+
await seedAuthedUser(t, { deletedAt: Date.now() - 60_000 });
|
|
75
|
+
|
|
76
|
+
// authMutation -> requireAuthenticatedUser -> authenticationRequired().
|
|
77
|
+
await expect(t.mutation(api.users.restoreAccount, {})).rejects.toThrowError(ConvexError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("throttles the criticalAction bucket: the call past capacity throws", async () => {
|
|
81
|
+
const t = initConvexTest();
|
|
82
|
+
const { authUserId, sessionId } = await seedAuthedUser(t); // no tombstone
|
|
83
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
84
|
+
|
|
85
|
+
// criticalAction is a capacity-5 token bucket. restoreAccount runs the rate
|
|
86
|
+
// check before its idempotent early-return and (unlike deleteAccount) never
|
|
87
|
+
// revokes the session, so it's safely repeatable with one identity. The
|
|
88
|
+
// first 5 rapid calls consume the bucket; the 6th has no token and throws.
|
|
89
|
+
// Guards rateLimitWithThrow against a dropped `throws`, a wrong bucket name,
|
|
90
|
+
// or a removed call. (A throttled real user just retries after refill.)
|
|
91
|
+
for (let i = 0; i < 5; i++) {
|
|
92
|
+
await asUser.mutation(api.users.restoreAccount, {});
|
|
93
|
+
}
|
|
94
|
+
await expect(asUser.mutation(api.users.restoreAccount, {})).rejects.toThrowError(ConvexError);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for users.updateAvatar (authMutation).
|
|
4
|
+
*
|
|
5
|
+
* updateAvatar(storageId):
|
|
6
|
+
* - rate-limits on the "userAction" bucket (needs rateLimiter component)
|
|
7
|
+
* - deletes the previous uploaded avatar from storage if one exists
|
|
8
|
+
* - patches the app users row: avatar = storageId, bumps updatedAt
|
|
9
|
+
* - returns { avatarUrl } resolved via ctx.storage.getUrl(storageId)
|
|
10
|
+
*
|
|
11
|
+
* Auth is driven the same way as _auth-harness.test.ts: seed a Better Auth
|
|
12
|
+
* user + unexpired session in the component db, seed the mirrored app users
|
|
13
|
+
* row keyed by authId, then call with a matching identity (subject == auth
|
|
14
|
+
* user id, sessionId == session id, both REAL component doc ids).
|
|
15
|
+
*/
|
|
16
|
+
import { ConvexError } from "convex/values";
|
|
17
|
+
import { describe, expect, test } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { api } from "@/convex/_generated/api";
|
|
20
|
+
|
|
21
|
+
import { initConvexTest, seedAuthedUser, identityFor, type AuthedTest } from "./_harness";
|
|
22
|
+
|
|
23
|
+
/** Store a real blob and return its _storage id, the same shape the handler patches. */
|
|
24
|
+
async function storeBlob(t: AuthedTest, body: string) {
|
|
25
|
+
return await t.run(async (ctx) => ctx.storage.store(new Blob([body])));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("users.updateAvatar", () => {
|
|
29
|
+
test("authed: patches avatar to the storage id and returns its url", async () => {
|
|
30
|
+
const t = initConvexTest();
|
|
31
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
32
|
+
const storageId = await storeBlob(t, "new-avatar-bytes");
|
|
33
|
+
|
|
34
|
+
const before = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
35
|
+
expect(before?.avatar).toBeUndefined(); // no avatar to start
|
|
36
|
+
|
|
37
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
38
|
+
const result = await asUser.mutation(api.users.updateAvatar, { storageId });
|
|
39
|
+
|
|
40
|
+
// Return shape: a resolvable url for the stored blob (not null).
|
|
41
|
+
expect(result.avatarUrl).toEqual(expect.any(String));
|
|
42
|
+
|
|
43
|
+
// Real DB effect: the app users row now points at the new storage id
|
|
44
|
+
// and updatedAt was bumped past its seed value.
|
|
45
|
+
const after = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
46
|
+
expect(after?.avatar).toBe(storageId);
|
|
47
|
+
expect(after!.updatedAt).toBeGreaterThanOrEqual(before!.updatedAt);
|
|
48
|
+
|
|
49
|
+
// The stored blob is still reachable (it's the current avatar).
|
|
50
|
+
const url = await t.run(async (ctx) => ctx.storage.getUrl(storageId));
|
|
51
|
+
expect(url).not.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("authed: replacing an avatar deletes the previous blob from storage", async () => {
|
|
55
|
+
const t = initConvexTest();
|
|
56
|
+
const oldStorageId = await storeBlob(t, "original-bytes");
|
|
57
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
58
|
+
// Start the app row with an existing avatar so the handler deletes the old blob.
|
|
59
|
+
await t.run(async (ctx) => ctx.db.patch(appUserId, { avatar: oldStorageId }));
|
|
60
|
+
|
|
61
|
+
// Sanity: the old blob exists before we replace it.
|
|
62
|
+
expect(await t.run(async (ctx) => ctx.storage.getUrl(oldStorageId))).not.toBeNull();
|
|
63
|
+
|
|
64
|
+
const newStorageId = await storeBlob(t, "replacement-bytes");
|
|
65
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
66
|
+
await asUser.mutation(api.users.updateAvatar, { storageId: newStorageId });
|
|
67
|
+
|
|
68
|
+
// The row now points at the new blob...
|
|
69
|
+
const after = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
70
|
+
expect(after?.avatar).toBe(newStorageId);
|
|
71
|
+
|
|
72
|
+
// ...and the OLD blob was deleted from storage (getUrl returns null).
|
|
73
|
+
expect(await t.run(async (ctx) => ctx.storage.getUrl(oldStorageId))).toBeNull();
|
|
74
|
+
// New blob is still present.
|
|
75
|
+
expect(await t.run(async (ctx) => ctx.storage.getUrl(newStorageId))).not.toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("unauthenticated: throws ConvexError and writes nothing", async () => {
|
|
79
|
+
const t = initConvexTest();
|
|
80
|
+
const { appUserId } = await seedAuthedUser(t); // data exists; we just don't authenticate
|
|
81
|
+
const storageId = await storeBlob(t, "orphan-bytes");
|
|
82
|
+
|
|
83
|
+
// authMutation requires a user -> authenticationRequired() (a ConvexError).
|
|
84
|
+
await expect(t.mutation(api.users.updateAvatar, { storageId })).rejects.toThrowError(
|
|
85
|
+
ConvexError,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// The guarded write never landed.
|
|
89
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
90
|
+
expect(row?.avatar).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for the authed mutation `users.updateProfile`.
|
|
4
|
+
*
|
|
5
|
+
* updateProfile is an authMutation that:
|
|
6
|
+
* - rate-limits on the "userAction" bucket,
|
|
7
|
+
* - validates bio (<= 500 chars) and throws a VAL_3001 ConvexError otherwise,
|
|
8
|
+
* - patches the app `users` row's bio + updatedAt,
|
|
9
|
+
* - returns the app user _id.
|
|
10
|
+
*
|
|
11
|
+
* Auth resolves through Better Auth: ctx.auth.getUserIdentity() (root ctx) ->
|
|
12
|
+
* betterAuth component session findOne (_id == identity.sessionId, expiresAt >
|
|
13
|
+
* now) -> betterAuth user findOne (_id == identity.subject) -> app `users` row
|
|
14
|
+
* by index "authId" == betterAuth user id. So we seed three rows and hand
|
|
15
|
+
* t.withIdentity an identity whose subject/sessionId are the REAL component
|
|
16
|
+
* doc ids. (Same harness shape proven in `_auth-harness.test.ts`.)
|
|
17
|
+
*/
|
|
18
|
+
import { ConvexError } from "convex/values";
|
|
19
|
+
import { describe, expect, test } from "vitest";
|
|
20
|
+
|
|
21
|
+
import { api } from "@/convex/_generated/api";
|
|
22
|
+
|
|
23
|
+
import { identityFor, initConvexTest, seedAuthedUser, type AuthedTest } from "./_harness";
|
|
24
|
+
|
|
25
|
+
type SeededAppUserId = Awaited<ReturnType<typeof seedAuthedUser>>["appUserId"];
|
|
26
|
+
|
|
27
|
+
/** The shared seedAuthedUser doesn't seed bio, so set it directly on the row. */
|
|
28
|
+
async function setBio(t: AuthedTest, appUserId: SeededAppUserId, bio: string) {
|
|
29
|
+
await t.run(async (ctx) => ctx.db.patch(appUserId, { bio }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("users.updateProfile", () => {
|
|
33
|
+
test("authenticated: patches bio on the real users row and returns its id", async () => {
|
|
34
|
+
const t = initConvexTest();
|
|
35
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
36
|
+
await setBio(t, appUserId, "old bio");
|
|
37
|
+
|
|
38
|
+
// Snapshot updatedAt before so we can prove the patch moved it.
|
|
39
|
+
const before = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
40
|
+
expect(before?.bio).toBe("old bio");
|
|
41
|
+
const updatedAtBefore = before!.updatedAt;
|
|
42
|
+
|
|
43
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
44
|
+
const returnedId = await asUser.mutation(api.users.updateProfile, {
|
|
45
|
+
bio: "Countess of computing.",
|
|
46
|
+
});
|
|
47
|
+
expect(returnedId).toBe(appUserId);
|
|
48
|
+
|
|
49
|
+
// Real DB effect: read the row straight from the table.
|
|
50
|
+
const after = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
51
|
+
expect(after?.bio).toBe("Countess of computing.");
|
|
52
|
+
// updatedAt is bumped by the handler (Date.now()), never backwards.
|
|
53
|
+
expect(after!.updatedAt).toBeGreaterThanOrEqual(updatedAtBefore);
|
|
54
|
+
|
|
55
|
+
// Surfaces through the authed read path too.
|
|
56
|
+
const me = await asUser.query(api.users.getMe, {});
|
|
57
|
+
expect(me!.bio).toBe("Countess of computing.");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("authenticated: bio at the 500-char limit is accepted", async () => {
|
|
61
|
+
const t = initConvexTest();
|
|
62
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
63
|
+
|
|
64
|
+
const maxBio = "a".repeat(500);
|
|
65
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
66
|
+
await asUser.mutation(api.users.updateProfile, { bio: maxBio });
|
|
67
|
+
|
|
68
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
69
|
+
expect(row?.bio).toBe(maxBio);
|
|
70
|
+
expect(row?.bio?.length).toBe(500);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("authenticated: bio over 500 chars is rejected as a VAL_3001 ConvexError, no write", async () => {
|
|
74
|
+
const t = initConvexTest();
|
|
75
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
76
|
+
await setBio(t, appUserId, "untouched");
|
|
77
|
+
|
|
78
|
+
const tooLong = "a".repeat(501);
|
|
79
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
80
|
+
|
|
81
|
+
await expect(asUser.mutation(api.users.updateProfile, { bio: tooLong })).rejects.toThrowError(
|
|
82
|
+
ConvexError,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Assert the structured error code + field, and that the bad write was
|
|
86
|
+
// rejected before touching the row (mutation transaction rolled back).
|
|
87
|
+
let caught: unknown;
|
|
88
|
+
try {
|
|
89
|
+
await asUser.mutation(api.users.updateProfile, { bio: tooLong });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
caught = err;
|
|
92
|
+
}
|
|
93
|
+
expect(caught).toBeInstanceOf(ConvexError);
|
|
94
|
+
const data = (caught as ConvexError<{ code: string; field?: string }>).data;
|
|
95
|
+
expect(data.code).toBe("VAL_3001");
|
|
96
|
+
expect(data.field).toBe("bio");
|
|
97
|
+
|
|
98
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
99
|
+
expect(row?.bio).toBe("untouched");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("unauthenticated: the authMutation throws a ConvexError and writes nothing", async () => {
|
|
103
|
+
const t = initConvexTest();
|
|
104
|
+
// Seed the data, but call WITHOUT an identity. requireAuthenticatedUser
|
|
105
|
+
// throws authenticationRequired() (AUTH_1001) before the handler runs.
|
|
106
|
+
const { appUserId } = await seedAuthedUser(t);
|
|
107
|
+
await setBio(t, appUserId, "before");
|
|
108
|
+
|
|
109
|
+
await expect(t.mutation(api.users.updateProfile, { bio: "after" })).rejects.toThrowError(
|
|
110
|
+
ConvexError,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
let caught: unknown;
|
|
114
|
+
try {
|
|
115
|
+
await t.mutation(api.users.updateProfile, { bio: "after" });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
caught = err;
|
|
118
|
+
}
|
|
119
|
+
expect(caught).toBeInstanceOf(ConvexError);
|
|
120
|
+
expect((caught as ConvexError<{ code: string }>).data.code).toBe("AUTH_1001");
|
|
121
|
+
|
|
122
|
+
// No write happened.
|
|
123
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
124
|
+
expect(row?.bio).toBe("before");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -290,6 +290,37 @@ describe("withWebhook (HMAC signature verification)", () => {
|
|
|
290
290
|
expect(res.status).toBe(200);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
test("401 when replay timestamp is in the future", async () => {
|
|
294
|
+
// The window is two-sided (Math.abs of the age), so a forward-skewed clock
|
|
295
|
+
// is rejected too. A one-sided `age > max` check would pass every other
|
|
296
|
+
// replay test but accept arbitrarily-future timestamps; this pins that.
|
|
297
|
+
const handler = withWebhook(
|
|
298
|
+
{
|
|
299
|
+
source: "test",
|
|
300
|
+
signatureHeader: "x-signature",
|
|
301
|
+
secretEnv: "TEST_WEBHOOK_SECRET",
|
|
302
|
+
algorithm: "sha256",
|
|
303
|
+
replay: { header: "x-timestamp", maxAgeSeconds: 60 },
|
|
304
|
+
},
|
|
305
|
+
() => new Response("ok"),
|
|
306
|
+
);
|
|
307
|
+
const body = "{}";
|
|
308
|
+
const signature = await sign("sha256", SECRET, body);
|
|
309
|
+
const future = Date.now() + 120_000; // 2 minutes ahead, exceeds 60s window
|
|
310
|
+
const res = await handler(
|
|
311
|
+
ctx,
|
|
312
|
+
makeRequest({
|
|
313
|
+
body,
|
|
314
|
+
signatureHeader: "x-signature",
|
|
315
|
+
signatureValue: signature,
|
|
316
|
+
timestampHeader: "x-timestamp",
|
|
317
|
+
timestampValue: String(future),
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
expect(res.status).toBe(401);
|
|
321
|
+
expect(await res.text()).toContain("timestamp out of window");
|
|
322
|
+
});
|
|
323
|
+
|
|
293
324
|
test("X-Request-Id header is set on every response", async () => {
|
|
294
325
|
const handler = withWebhook(
|
|
295
326
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("expo-linking", () => {
|
|
4
|
-
|
|
4
|
+
const parse = vi.fn((url: string) => {
|
|
5
5
|
try {
|
|
6
6
|
const u = new URL(url);
|
|
7
7
|
const queryParams: Record<string, string> = {};
|
|
@@ -17,12 +17,19 @@ vi.mock("expo-linking", () => {
|
|
|
17
17
|
} catch {
|
|
18
18
|
return { scheme: null, hostname: null, path: url, queryParams: null };
|
|
19
19
|
}
|
|
20
|
-
}
|
|
20
|
+
});
|
|
21
21
|
return { parse, createURL: (p: string) => p };
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
import { parse } from "expo-linking";
|
|
25
|
+
|
|
26
|
+
import { redirectSystemPath } from "@/app/+native-intent";
|
|
24
27
|
import { resolveDeepLink } from "@/lib/deep-link";
|
|
25
28
|
|
|
29
|
+
const parseMock = parse as unknown as ReturnType<typeof vi.fn>;
|
|
30
|
+
|
|
31
|
+
const redirect = (path: string) => redirectSystemPath!({ path, initial: false });
|
|
32
|
+
|
|
26
33
|
beforeEach(() => {
|
|
27
34
|
vi.clearAllMocks();
|
|
28
35
|
});
|
|
@@ -31,37 +38,75 @@ describe("resolveDeepLink", () => {
|
|
|
31
38
|
it("parses a valid path with query params", () => {
|
|
32
39
|
const result = resolveDeepLink("vexpo://app/linked?foo=bar&n=1");
|
|
33
40
|
expect(result.path).toBe("/linked");
|
|
41
|
+
expect(result.href).toBe("/linked");
|
|
34
42
|
expect(result.params).toEqual({ foo: "bar", n: "1" });
|
|
35
43
|
});
|
|
36
44
|
|
|
45
|
+
it("resolves a path alias to its canonical href", () => {
|
|
46
|
+
const result = resolveDeepLink("vexpo://app/about");
|
|
47
|
+
expect(result.path).toBe("/about");
|
|
48
|
+
expect(result.href).toBe("/help");
|
|
49
|
+
});
|
|
50
|
+
|
|
37
51
|
it("returns null path for disallowed routes", () => {
|
|
38
52
|
const result = resolveDeepLink("vexpo://app/admin");
|
|
39
53
|
expect(result.path).toBeNull();
|
|
54
|
+
expect(result.href).toBeNull();
|
|
40
55
|
expect(result.params).toEqual({});
|
|
41
56
|
});
|
|
42
57
|
|
|
43
58
|
it("returns null path for path traversal attempts", () => {
|
|
44
59
|
const result = resolveDeepLink("vexpo://app/../etc/passwd");
|
|
45
60
|
expect(result.path).toBeNull();
|
|
61
|
+
expect(result.href).toBeNull();
|
|
46
62
|
});
|
|
47
63
|
|
|
48
64
|
it("handles empty input", () => {
|
|
49
|
-
expect(resolveDeepLink("")).toEqual({ path: null, params: {} });
|
|
65
|
+
expect(resolveDeepLink("")).toEqual({ path: null, href: null, params: {} });
|
|
50
66
|
});
|
|
51
67
|
|
|
52
68
|
it("handles garbage input without throwing", () => {
|
|
53
69
|
expect(() => resolveDeepLink("not a url")).not.toThrow();
|
|
54
70
|
const result = resolveDeepLink("not a url");
|
|
55
71
|
expect(result.path).toBeNull();
|
|
72
|
+
expect(result.href).toBeNull();
|
|
56
73
|
});
|
|
57
74
|
|
|
58
75
|
it("normalizes trailing slashes", () => {
|
|
59
76
|
const result = resolveDeepLink("vexpo://app/linked/");
|
|
60
77
|
expect(result.path).toBe("/linked");
|
|
78
|
+
expect(result.href).toBe("/linked");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("drops nullish query values and joins array values", () => {
|
|
82
|
+
// URLSearchParams only yields strings, so the real source's `value == null`
|
|
83
|
+
// skip and `Array.isArray` join branches are unreachable through a real URL.
|
|
84
|
+
// Drive them directly via the parse mock.
|
|
85
|
+
parseMock.mockReturnValueOnce({
|
|
86
|
+
scheme: "vexpo",
|
|
87
|
+
hostname: "app",
|
|
88
|
+
path: "/linked",
|
|
89
|
+
queryParams: { a: "1", b: null, tags: ["x", "y"] },
|
|
90
|
+
});
|
|
91
|
+
const result = resolveDeepLink("vexpo://app/linked");
|
|
92
|
+
expect(result.params).toEqual({ a: "1", tags: "x,y" });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("redirectSystemPath (native-intent)", () => {
|
|
97
|
+
it("reattaches the query so params reach the route on the router's own navigation", () => {
|
|
98
|
+
expect(redirect("vexpo://app/linked?foo=bar&n=1")).toBe("/linked?foo=bar&n=1");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns the bare href when there's no query", () => {
|
|
102
|
+
expect(redirect("vexpo://app/about")).toBe("/help");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("carries the query across an alias rewrite", () => {
|
|
106
|
+
expect(redirect("vexpo://app/about?ref=email")).toBe("/help?ref=email");
|
|
61
107
|
});
|
|
62
108
|
|
|
63
|
-
it("
|
|
64
|
-
|
|
65
|
-
expect(result.params).toEqual({ x: "1" });
|
|
109
|
+
it("blocks unknown paths to /", () => {
|
|
110
|
+
expect(redirect("vexpo://app/admin")).toBe("/");
|
|
66
111
|
});
|
|
67
112
|
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PASSWORD_MAX_LENGTH,
|
|
5
|
+
PASSWORD_MIN_LENGTH,
|
|
6
|
+
firstError,
|
|
7
|
+
firstErrorField,
|
|
8
|
+
forgotPasswordSchema,
|
|
9
|
+
profileUpdateSchema,
|
|
10
|
+
resetPasswordSchema,
|
|
11
|
+
signInEmailSchema,
|
|
12
|
+
signInSchema,
|
|
13
|
+
signInUsernameSchema,
|
|
14
|
+
signUpSchema,
|
|
15
|
+
} from "@/lib/schemas";
|
|
16
|
+
|
|
17
|
+
// Validation behind the auth and profile forms. The screens render
|
|
18
|
+
// @expo/ui SwiftUI (which Maestro can't drive), so this is where the form
|
|
19
|
+
// logic is actually verified: transforms, bounds, format, reserved names,
|
|
20
|
+
// cross-field matching, and the inline-error helpers.
|
|
21
|
+
|
|
22
|
+
const validPassword = "a".repeat(PASSWORD_MIN_LENGTH);
|
|
23
|
+
|
|
24
|
+
describe("email validation", () => {
|
|
25
|
+
it("trims and lowercases a valid email", () => {
|
|
26
|
+
const r = forgotPasswordSchema.safeParse({ email: " Foo@Bar.COM " });
|
|
27
|
+
expect(r.success).toBe(true);
|
|
28
|
+
if (r.success) expect(r.data.email).toBe("foo@bar.com");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects a malformed email with the inline message", () => {
|
|
32
|
+
const r = forgotPasswordSchema.safeParse({ email: "not-an-email" });
|
|
33
|
+
expect(r.success).toBe(false);
|
|
34
|
+
expect(firstError(r)).toBe("Enter a valid email address");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("password bounds", () => {
|
|
39
|
+
it(`rejects shorter than ${PASSWORD_MIN_LENGTH}`, () => {
|
|
40
|
+
const r = signUpSchema.safeParse({
|
|
41
|
+
name: "Ray",
|
|
42
|
+
username: "",
|
|
43
|
+
email: "r@example.com",
|
|
44
|
+
password: "a".repeat(PASSWORD_MIN_LENGTH - 1),
|
|
45
|
+
});
|
|
46
|
+
expect(r.success).toBe(false);
|
|
47
|
+
expect(firstError(r)).toBe(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it(`rejects longer than ${PASSWORD_MAX_LENGTH}`, () => {
|
|
51
|
+
const r = signUpSchema.safeParse({
|
|
52
|
+
name: "Ray",
|
|
53
|
+
username: "",
|
|
54
|
+
email: "r@example.com",
|
|
55
|
+
password: "a".repeat(PASSWORD_MAX_LENGTH + 1),
|
|
56
|
+
});
|
|
57
|
+
expect(r.success).toBe(false);
|
|
58
|
+
expect(firstError(r)).toBe(`Password must be ${PASSWORD_MAX_LENGTH} characters or fewer`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("accepts a password at the minimum length", () => {
|
|
62
|
+
const r = signUpSchema.safeParse({
|
|
63
|
+
name: "Ray",
|
|
64
|
+
username: "",
|
|
65
|
+
email: "r@example.com",
|
|
66
|
+
password: validPassword,
|
|
67
|
+
});
|
|
68
|
+
expect(r.success).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("required username (profileUpdateSchema)", () => {
|
|
73
|
+
it("lowercases and trims a valid username", () => {
|
|
74
|
+
const r = profileUpdateSchema.safeParse({
|
|
75
|
+
name: "Ray",
|
|
76
|
+
username: " RayC ",
|
|
77
|
+
email: "r@example.com",
|
|
78
|
+
});
|
|
79
|
+
expect(r.success).toBe(true);
|
|
80
|
+
if (r.success) expect(r.data.username).toBe("rayc");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects too-short usernames", () => {
|
|
84
|
+
const r = profileUpdateSchema.safeParse({
|
|
85
|
+
name: "Ray",
|
|
86
|
+
username: "ab",
|
|
87
|
+
email: "r@example.com",
|
|
88
|
+
});
|
|
89
|
+
expect(r.success).toBe(false);
|
|
90
|
+
expect(firstErrorField(r)).toBe("username");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects invalid characters with the format message", () => {
|
|
94
|
+
const r = profileUpdateSchema.safeParse({
|
|
95
|
+
name: "Ray",
|
|
96
|
+
username: "bad name!",
|
|
97
|
+
email: "r@example.com",
|
|
98
|
+
});
|
|
99
|
+
expect(firstError(r)).toBe("Letters, numbers, dots, and underscores only");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects a reserved username, case-insensitively", () => {
|
|
103
|
+
const r = profileUpdateSchema.safeParse({
|
|
104
|
+
name: "Ray",
|
|
105
|
+
username: "ADMIN",
|
|
106
|
+
email: "r@example.com",
|
|
107
|
+
});
|
|
108
|
+
expect(firstError(r)).toBe("That username is reserved");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("rejects an empty username (required here, unlike sign-up)", () => {
|
|
112
|
+
const r = profileUpdateSchema.safeParse({ name: "Ray", username: "", email: "r@example.com" });
|
|
113
|
+
expect(r.success).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("optional username (signUpSchema)", () => {
|
|
118
|
+
it("accepts an empty username", () => {
|
|
119
|
+
const r = signUpSchema.safeParse({
|
|
120
|
+
name: "Ray",
|
|
121
|
+
username: "",
|
|
122
|
+
email: "r@example.com",
|
|
123
|
+
password: validPassword,
|
|
124
|
+
});
|
|
125
|
+
expect(r.success).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("still rejects a non-empty reserved username", () => {
|
|
129
|
+
const r = signUpSchema.safeParse({
|
|
130
|
+
name: "Ray",
|
|
131
|
+
username: "root",
|
|
132
|
+
email: "r@example.com",
|
|
133
|
+
password: validPassword,
|
|
134
|
+
});
|
|
135
|
+
expect(firstError(r)).toBe("That username is reserved");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("requires a non-empty name", () => {
|
|
139
|
+
const r = signUpSchema.safeParse({
|
|
140
|
+
name: " ",
|
|
141
|
+
username: "",
|
|
142
|
+
email: "r@example.com",
|
|
143
|
+
password: validPassword,
|
|
144
|
+
});
|
|
145
|
+
expect(r.success).toBe(false);
|
|
146
|
+
expect(firstError(r)).toBe("Name is required");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("sign-in schemas", () => {
|
|
151
|
+
it("signInSchema requires identifier and password", () => {
|
|
152
|
+
const r = signInSchema.safeParse({ identifier: "", password: "" });
|
|
153
|
+
expect(r.success).toBe(false);
|
|
154
|
+
expect(firstError(r)).toBe("Username or email is required");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("signInEmailSchema validates the email but takes any non-empty password", () => {
|
|
158
|
+
expect(signInEmailSchema.safeParse({ email: "r@example.com", password: "x" }).success).toBe(
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
expect(signInEmailSchema.safeParse({ email: "nope", password: "x" }).success).toBe(false);
|
|
162
|
+
expect(signInEmailSchema.safeParse({ email: "r@example.com", password: "" }).success).toBe(
|
|
163
|
+
false,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("signInUsernameSchema enforces the username minimum", () => {
|
|
168
|
+
expect(signInUsernameSchema.safeParse({ username: "ab", password: "x" }).success).toBe(false);
|
|
169
|
+
expect(signInUsernameSchema.safeParse({ username: "abc", password: "x" }).success).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("resetPasswordSchema", () => {
|
|
174
|
+
const base = { email: "r@example.com", otp: "123456", password: validPassword };
|
|
175
|
+
|
|
176
|
+
it("rejects an OTP that is not 6 digits", () => {
|
|
177
|
+
const r = resetPasswordSchema.safeParse({
|
|
178
|
+
...base,
|
|
179
|
+
otp: "12345",
|
|
180
|
+
confirmPassword: validPassword,
|
|
181
|
+
});
|
|
182
|
+
expect(r.success).toBe(false);
|
|
183
|
+
expect(firstError(r)).toBe("Enter the 6-digit code");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects mismatched passwords, flagged on confirmPassword", () => {
|
|
187
|
+
const r = resetPasswordSchema.safeParse({ ...base, confirmPassword: `${validPassword}x` });
|
|
188
|
+
expect(r.success).toBe(false);
|
|
189
|
+
expect(firstError(r)).toBe("Passwords do not match");
|
|
190
|
+
expect(firstErrorField(r)).toBe("confirmPassword");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("accepts a valid reset with matching passwords", () => {
|
|
194
|
+
const r = resetPasswordSchema.safeParse({ ...base, confirmPassword: validPassword });
|
|
195
|
+
expect(r.success).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("error helpers", () => {
|
|
200
|
+
it("firstError and firstErrorField return null on success", () => {
|
|
201
|
+
const r = forgotPasswordSchema.safeParse({ email: "r@example.com" });
|
|
202
|
+
expect(firstError(r)).toBeNull();
|
|
203
|
+
expect(firstErrorField(r)).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
});
|