@ramonclaudio/create-vexpo 0.1.0 → 0.1.2
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,146 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* REAL convexTest coverage for pushTokens.upsert (authMutation).
|
|
4
|
+
*
|
|
5
|
+
* upsert insert-or-updates a single Expo push token row for the current user.
|
|
6
|
+
* It rate-limits on "userAction" (so the rateLimiter component must be
|
|
7
|
+
* registered) and reads existing rows by the "by_token" index, so the same
|
|
8
|
+
* token never duplicates: same owner -> patch + un-revoke; different owner ->
|
|
9
|
+
* reassign to the caller.
|
|
10
|
+
*
|
|
11
|
+
* Auth is driven exactly like _auth-harness.test.ts: seed a Better Auth
|
|
12
|
+
* user + session in the component db (capturing their REAL component ids),
|
|
13
|
+
* seed the mirrored app `users` row keyed by authId, then call with an
|
|
14
|
+
* identity whose subject == better-auth user id and sessionId == session id.
|
|
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 { identityFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
22
|
+
|
|
23
|
+
describe("pushTokens.upsert", () => {
|
|
24
|
+
test("inserts a new token row owned by the authenticated user", async () => {
|
|
25
|
+
const t = initConvexTest();
|
|
26
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
27
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
28
|
+
|
|
29
|
+
const before = Date.now();
|
|
30
|
+
const tokenId = await asUser.mutation(api.pushTokens.upsert, {
|
|
31
|
+
token: "ExponentPushToken[aaa]",
|
|
32
|
+
deviceType: "ios",
|
|
33
|
+
});
|
|
34
|
+
const after = Date.now();
|
|
35
|
+
|
|
36
|
+
// Real DB effect: exactly one row, keyed to the app user, with the values
|
|
37
|
+
// the handler wrote.
|
|
38
|
+
const rows = await t.run(async (ctx) => ctx.db.query("pushTokens").collect());
|
|
39
|
+
expect(rows).toHaveLength(1);
|
|
40
|
+
const row = rows[0];
|
|
41
|
+
expect(row._id).toBe(tokenId);
|
|
42
|
+
expect(row.userId).toBe(appUserId);
|
|
43
|
+
expect(row.token).toBe("ExponentPushToken[aaa]");
|
|
44
|
+
expect(row.deviceType).toBe("ios");
|
|
45
|
+
// Insert path sets createdAt == updatedAt == lastSeenAt to "now".
|
|
46
|
+
expect(row.createdAt).toBeGreaterThanOrEqual(before);
|
|
47
|
+
expect(row.createdAt).toBeLessThanOrEqual(after);
|
|
48
|
+
expect(row.updatedAt).toBe(row.createdAt);
|
|
49
|
+
expect(row.lastSeenAt).toBe(row.createdAt);
|
|
50
|
+
// Fresh insert is active (revoked: false) so the cleanup index range
|
|
51
|
+
// [revoked, updatedAt] covers it, and carries no error code.
|
|
52
|
+
expect(row.revoked).toBe(false);
|
|
53
|
+
expect(row.revokedAt).toBeUndefined();
|
|
54
|
+
expect(row.lastErrorCode).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("re-upserting the same token patches the row instead of duplicating, and clears revocation", async () => {
|
|
58
|
+
const t = initConvexTest();
|
|
59
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
60
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
61
|
+
|
|
62
|
+
// First upsert creates the row.
|
|
63
|
+
const firstId = await asUser.mutation(api.pushTokens.upsert, {
|
|
64
|
+
token: "ExponentPushToken[dup]",
|
|
65
|
+
deviceType: "ios",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Simulate a revoked/dead token the way markRevoked would: set the
|
|
69
|
+
// tombstone fields and a stale createdAt so we can prove createdAt is
|
|
70
|
+
// preserved across the patch path while updatedAt/lastSeenAt advance.
|
|
71
|
+
const stale = Date.now() - 60_000;
|
|
72
|
+
await t.run(async (ctx) => {
|
|
73
|
+
await ctx.db.patch(firstId, {
|
|
74
|
+
createdAt: stale,
|
|
75
|
+
updatedAt: stale,
|
|
76
|
+
lastSeenAt: stale,
|
|
77
|
+
revoked: true,
|
|
78
|
+
revokedAt: stale,
|
|
79
|
+
lastErrorCode: "DeviceNotRegistered",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Second upsert of the SAME token by the SAME user.
|
|
84
|
+
const before = Date.now();
|
|
85
|
+
const secondId = await asUser.mutation(api.pushTokens.upsert, {
|
|
86
|
+
token: "ExponentPushToken[dup]",
|
|
87
|
+
deviceType: "ios",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Idempotent: same row id back, still exactly one row in the table.
|
|
91
|
+
expect(secondId).toBe(firstId);
|
|
92
|
+
const rows = await t.run(async (ctx) => ctx.db.query("pushTokens").collect());
|
|
93
|
+
expect(rows).toHaveLength(1);
|
|
94
|
+
|
|
95
|
+
const row = rows[0];
|
|
96
|
+
expect(row.userId).toBe(appUserId);
|
|
97
|
+
// createdAt preserved, updatedAt + lastSeenAt refreshed forward.
|
|
98
|
+
expect(row.createdAt).toBe(stale);
|
|
99
|
+
expect(row.updatedAt).toBeGreaterThanOrEqual(before);
|
|
100
|
+
expect(row.lastSeenAt).toBeGreaterThanOrEqual(before);
|
|
101
|
+
// Revocation cleared: the same-user patch un-revokes and drops the error.
|
|
102
|
+
expect(row.revoked).toBe(false);
|
|
103
|
+
expect(row.revokedAt).toBeUndefined();
|
|
104
|
+
expect(row.lastErrorCode).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("reassigns a token to the current user when it was owned by someone else", async () => {
|
|
108
|
+
const t = initConvexTest();
|
|
109
|
+
const owner = await seedAuthedUser(t);
|
|
110
|
+
const taker = await seedAuthedUser(t);
|
|
111
|
+
|
|
112
|
+
const asOwner = t.withIdentity(identityFor(owner.authUserId, owner.sessionId));
|
|
113
|
+
const asTaker = t.withIdentity(identityFor(taker.authUserId, taker.sessionId));
|
|
114
|
+
|
|
115
|
+
// Owner registers the token first.
|
|
116
|
+
const ownerTokenId = await asOwner.mutation(api.pushTokens.upsert, {
|
|
117
|
+
token: "ExponentPushToken[shared]",
|
|
118
|
+
deviceType: "ios",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Same physical token now upserted by a different signed-in user (device
|
|
122
|
+
// changed hands). Handler reassigns the existing row rather than inserting.
|
|
123
|
+
const takerTokenId = await asTaker.mutation(api.pushTokens.upsert, {
|
|
124
|
+
token: "ExponentPushToken[shared]",
|
|
125
|
+
deviceType: "ios",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(takerTokenId).toBe(ownerTokenId); // reused, not a new row
|
|
129
|
+
const rows = await t.run(async (ctx) => ctx.db.query("pushTokens").collect());
|
|
130
|
+
expect(rows).toHaveLength(1);
|
|
131
|
+
// Ownership transferred to the taker's app user id.
|
|
132
|
+
expect(rows[0].userId).toBe(taker.appUserId);
|
|
133
|
+
expect(rows[0].userId).not.toBe(owner.appUserId);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("throws ConvexError when called without authentication", async () => {
|
|
137
|
+
const t = initConvexTest();
|
|
138
|
+
await expect(
|
|
139
|
+
t.mutation(api.pushTokens.upsert, { token: "ExponentPushToken[anon]", deviceType: "ios" }),
|
|
140
|
+
).rejects.toThrowError(ConvexError);
|
|
141
|
+
|
|
142
|
+
// And no row leaked into the table from the rejected call.
|
|
143
|
+
const rows = await t.run(async (ctx) => ctx.db.query("pushTokens").collect());
|
|
144
|
+
expect(rows).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Authenticated convexTest coverage for `users.deleteAccount`.
|
|
4
|
+
*
|
|
5
|
+
* `deleteAccount` is an authMutation that soft-deletes the current user:
|
|
6
|
+
* - rate-limits on the `criticalAction` bucket (rateLimiter component)
|
|
7
|
+
* - drops the user's push tokens so notifications stop
|
|
8
|
+
* - revokes Better Auth sessions for the user
|
|
9
|
+
* - patches the app users row with `deletedAt`
|
|
10
|
+
* - writes a `requested` row to `accountDeletionAudit`
|
|
11
|
+
* - is idempotent: a second call no-ops and returns the original deletedAt
|
|
12
|
+
*
|
|
13
|
+
* Auth resolution mirrors the proven harness at
|
|
14
|
+
* __tests__/convex/_auth-harness.test.ts: seed a Better Auth user + unexpired
|
|
15
|
+
* session in the component db, seed the mirrored app `users` row keyed by
|
|
16
|
+
* authId, then drive calls with an identity whose subject == better-auth user
|
|
17
|
+
* id and sessionId == better-auth session id (both REAL component doc ids).
|
|
18
|
+
*/
|
|
19
|
+
import { ConvexError } from "convex/values";
|
|
20
|
+
import { describe, expect, test } from "vitest";
|
|
21
|
+
|
|
22
|
+
import { api } from "@/convex/_generated/api";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
auditRowsFor,
|
|
26
|
+
componentSessionsFor,
|
|
27
|
+
identityFor,
|
|
28
|
+
initConvexTest,
|
|
29
|
+
seedAuthedUser,
|
|
30
|
+
} from "./_harness";
|
|
31
|
+
|
|
32
|
+
const FAR_FUTURE = Date.now() + 7 * 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
describe("users.deleteAccount", () => {
|
|
35
|
+
test("happy path: tombstones the user, drops push tokens, writes an audit row", async () => {
|
|
36
|
+
const t = initConvexTest();
|
|
37
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
38
|
+
|
|
39
|
+
// Give the user a push token so we can prove deleteAccount removes it.
|
|
40
|
+
const tokenNow = Date.now();
|
|
41
|
+
const pushTokenId = await t.run(async (ctx) =>
|
|
42
|
+
ctx.db.insert("pushTokens", {
|
|
43
|
+
userId: appUserId,
|
|
44
|
+
token: "ExponentPushToken[abc123]",
|
|
45
|
+
deviceType: "ios",
|
|
46
|
+
createdAt: tokenNow,
|
|
47
|
+
updatedAt: tokenNow,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Sanity: the row starts un-tombstoned with one live session.
|
|
52
|
+
const before = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
53
|
+
expect(before?.deletedAt).toBeUndefined();
|
|
54
|
+
expect(await componentSessionsFor(t, authUserId)).toHaveLength(1);
|
|
55
|
+
|
|
56
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
57
|
+
const beforeCall = Date.now();
|
|
58
|
+
const result = await asUser.mutation(api.users.deleteAccount, {});
|
|
59
|
+
const afterCall = Date.now();
|
|
60
|
+
|
|
61
|
+
// Return value: success + a deletedAt timestamp inside the call window.
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
expect(result.deletedAt).toBeGreaterThanOrEqual(beforeCall);
|
|
64
|
+
expect(result.deletedAt).toBeLessThanOrEqual(afterCall);
|
|
65
|
+
|
|
66
|
+
// Real DB effect: the users row now carries the same deletedAt.
|
|
67
|
+
const after = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
68
|
+
expect(after?.deletedAt).toBe(result.deletedAt);
|
|
69
|
+
expect(after?.updatedAt).toBe(result.deletedAt);
|
|
70
|
+
|
|
71
|
+
// Push token deleted so notifications stop.
|
|
72
|
+
const tokenGone = await t.run(async (ctx) => ctx.db.get(pushTokenId));
|
|
73
|
+
expect(tokenGone).toBeNull();
|
|
74
|
+
|
|
75
|
+
// Better Auth sessions revoked so the user's other devices are signed out.
|
|
76
|
+
// Without this assertion, dropping the session-revocation line would leave
|
|
77
|
+
// every deleteAccount test green.
|
|
78
|
+
expect(await componentSessionsFor(t, authUserId)).toHaveLength(0);
|
|
79
|
+
|
|
80
|
+
// Exactly one "requested" audit row keyed to this user.
|
|
81
|
+
const audit = await auditRowsFor(t, appUserId);
|
|
82
|
+
expect(audit).toHaveLength(1);
|
|
83
|
+
expect(audit[0]!.event).toBe("requested");
|
|
84
|
+
expect(audit[0]!.authId).toBe(authUserId);
|
|
85
|
+
expect(audit[0]!.at).toBe(result.deletedAt);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("idempotent: a second call no-ops and does not write a duplicate audit row", async () => {
|
|
89
|
+
const t = initConvexTest();
|
|
90
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
91
|
+
|
|
92
|
+
const first = await t
|
|
93
|
+
.withIdentity(identityFor(authUserId, sessionId))
|
|
94
|
+
.mutation(api.users.deleteAccount, {});
|
|
95
|
+
|
|
96
|
+
// deleteAccount revoked every session for this user, so the first
|
|
97
|
+
// identity no longer resolves. A returning user signs back in within the
|
|
98
|
+
// grace window (fresh session) before the client retries. Seed that
|
|
99
|
+
// session and drive the retry through it, same user, already tombstoned.
|
|
100
|
+
const retryNow = Date.now();
|
|
101
|
+
const retrySessionId = await t.runInComponent("betterAuth", async (ctx) => {
|
|
102
|
+
const session = await ctx.db.insert("session", {
|
|
103
|
+
userId: authUserId,
|
|
104
|
+
token: `tok_retry_${authUserId}`,
|
|
105
|
+
expiresAt: FAR_FUTURE,
|
|
106
|
+
createdAt: retryNow,
|
|
107
|
+
updatedAt: retryNow,
|
|
108
|
+
});
|
|
109
|
+
return session as string;
|
|
110
|
+
});
|
|
111
|
+
const asUser = t.withIdentity(identityFor(authUserId, retrySessionId));
|
|
112
|
+
|
|
113
|
+
// Second call returns the ORIGINAL deletedAt and touches nothing new.
|
|
114
|
+
const second = await asUser.mutation(api.users.deleteAccount, {});
|
|
115
|
+
expect(second.success).toBe(true);
|
|
116
|
+
expect(second.deletedAt).toBe(first.deletedAt);
|
|
117
|
+
|
|
118
|
+
// deletedAt unchanged on the row.
|
|
119
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
120
|
+
expect(row?.deletedAt).toBe(first.deletedAt);
|
|
121
|
+
|
|
122
|
+
// Still exactly one audit row: the no-op path inserts nothing.
|
|
123
|
+
const audit = await auditRowsFor(t, appUserId);
|
|
124
|
+
expect(audit).toHaveLength(1);
|
|
125
|
+
expect(audit[0]!.event).toBe("requested");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("authMutation guard: rejects with ConvexError when unauthenticated", async () => {
|
|
129
|
+
const t = initConvexTest();
|
|
130
|
+
// Seed a real user so the table is non-empty; we just never pass identity.
|
|
131
|
+
const { appUserId } = await seedAuthedUser(t);
|
|
132
|
+
|
|
133
|
+
await expect(t.mutation(api.users.deleteAccount, {})).rejects.toThrowError(ConvexError);
|
|
134
|
+
|
|
135
|
+
// And the guard ran before any write: the row is untouched, no audit row.
|
|
136
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
137
|
+
expect(row?.deletedAt).toBeUndefined();
|
|
138
|
+
expect(await auditRowsFor(t, appUserId)).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for `users.deleteAvatar` (authMutation).
|
|
4
|
+
*
|
|
5
|
+
* deleteAvatar (convex/users.ts):
|
|
6
|
+
* 1. rateLimitWithThrow(ctx, "userAction", ...) -> needs rateLimiter component
|
|
7
|
+
* 2. if (ctx.user.avatar) ctx.storage.delete(ctx.user.avatar)
|
|
8
|
+
* 3. ctx.db.patch(ctx.user._id, { avatar: undefined, updatedAt: now })
|
|
9
|
+
* 4. return { success: true }
|
|
10
|
+
*
|
|
11
|
+
* Auth resolves exactly as the harness in `_auth-harness.test.ts` documents:
|
|
12
|
+
* better-auth `user` + unexpired `session` rows whose REAL component ids match
|
|
13
|
+
* identity.subject / identity.sessionId, plus the mirrored app `users` row keyed
|
|
14
|
+
* by authId. See that file for the full walkthrough.
|
|
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 { identityFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
22
|
+
|
|
23
|
+
describe("users.deleteAvatar", () => {
|
|
24
|
+
test("clears the avatar field and frees the storage blob", async () => {
|
|
25
|
+
const t = initConvexTest();
|
|
26
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
27
|
+
|
|
28
|
+
// Store a real avatar blob and attach it to the app users row, then
|
|
29
|
+
// capture the row's updatedAt so we can prove the patch bumps it.
|
|
30
|
+
const { storageId, updatedAtBefore } = await t.run(async (ctx) => {
|
|
31
|
+
const id = await ctx.storage.store(new Blob(["avatar-bytes"], { type: "image/png" }));
|
|
32
|
+
await ctx.db.patch(appUserId, { avatar: id, updatedAt: 1 });
|
|
33
|
+
const row = await ctx.db.get(appUserId);
|
|
34
|
+
return { storageId: id, updatedAtBefore: row!.updatedAt };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Precondition: the blob is actually retrievable before deletion.
|
|
38
|
+
const urlBefore = await t.run(async (ctx) => ctx.storage.getUrl(storageId));
|
|
39
|
+
expect(urlBefore).not.toBeNull();
|
|
40
|
+
expect(updatedAtBefore).toBe(1);
|
|
41
|
+
|
|
42
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
43
|
+
const result = await asUser.mutation(api.users.deleteAvatar, {});
|
|
44
|
+
expect(result).toEqual({ success: true });
|
|
45
|
+
|
|
46
|
+
// DB effect: avatar field is cleared and updatedAt moved off the stale 1.
|
|
47
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
48
|
+
expect(row!.avatar).toBeUndefined();
|
|
49
|
+
expect(row!.updatedAt).toBeGreaterThan(updatedAtBefore);
|
|
50
|
+
|
|
51
|
+
// Storage effect: the blob is gone (deleted ids resolve to a null url).
|
|
52
|
+
const urlAfter = await t.run(async (ctx) => ctx.storage.getUrl(storageId));
|
|
53
|
+
expect(urlAfter).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("is a no-op on storage when there is no avatar, still returns success", async () => {
|
|
57
|
+
const t = initConvexTest();
|
|
58
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
59
|
+
|
|
60
|
+
// Seed an unrelated stored blob to prove the no-avatar path doesn't touch
|
|
61
|
+
// storage indiscriminately (the `if (ctx.user.avatar)` guard).
|
|
62
|
+
const bystanderId = await t.run(async (ctx) =>
|
|
63
|
+
ctx.storage.store(new Blob(["someone-elses-file"], { type: "image/png" })),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
67
|
+
const result = await asUser.mutation(api.users.deleteAvatar, {});
|
|
68
|
+
expect(result).toEqual({ success: true });
|
|
69
|
+
|
|
70
|
+
// The users row never had an avatar; it stays cleared.
|
|
71
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
72
|
+
expect(row!.avatar).toBeUndefined();
|
|
73
|
+
|
|
74
|
+
// The unrelated blob is untouched.
|
|
75
|
+
const bystanderUrl = await t.run(async (ctx) => ctx.storage.getUrl(bystanderId));
|
|
76
|
+
expect(bystanderUrl).not.toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("throws ConvexError when called unauthenticated", async () => {
|
|
80
|
+
const t = initConvexTest();
|
|
81
|
+
await seedAuthedUser(t); // data exists; we just don't present an identity
|
|
82
|
+
await expect(t.mutation(api.users.deleteAvatar, {})).rejects.toThrowError(ConvexError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an identity with an expired session is treated as unauthenticated", async () => {
|
|
86
|
+
const t = initConvexTest();
|
|
87
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t, {
|
|
88
|
+
expiresAt: Date.now() - 1000, // already expired
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Capture the seeded updatedAt so we can prove no patch ran.
|
|
92
|
+
const updatedAtBefore = await t.run(async (ctx) => {
|
|
93
|
+
const row = await ctx.db.get(appUserId);
|
|
94
|
+
return row!.updatedAt;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
98
|
+
await expect(asUser.mutation(api.users.deleteAvatar, {})).rejects.toThrowError(ConvexError);
|
|
99
|
+
|
|
100
|
+
// And the row is untouched: no patch ran, so updatedAt is still the seed value.
|
|
101
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
102
|
+
expect(row!.updatedAt).toBe(updatedAtBefore);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for the authed `users.getMe` query.
|
|
4
|
+
*
|
|
5
|
+
* `getMe` is an optionalAuthQuery returning the merged AuthUser when authed
|
|
6
|
+
* (app `users` row + Better Auth name/email/emailVerified) and null otherwise.
|
|
7
|
+
*
|
|
8
|
+
* Auth resolves through @convex-dev/better-auth's safeGetAuthUser:
|
|
9
|
+
* 1. identity = ctx.auth.getUserIdentity() (root ctx)
|
|
10
|
+
* 2. component.adapter.findOne(session) where
|
|
11
|
+
* _id == identity.sessionId AND expiresAt > now (betterAuth db)
|
|
12
|
+
* 3. component.adapter.findOne(user) where _id == identity.subject
|
|
13
|
+
* 4. auth.ts getUserByAuthId: app `users` row .withIndex("authId").
|
|
14
|
+
* The adapter resolves `_id` where-clauses via ctx.db.get, so the identity's
|
|
15
|
+
* subject/sessionId MUST be the real seeded component doc ids.
|
|
16
|
+
*
|
|
17
|
+
* Three seeded rows + one identity drive the authed path:
|
|
18
|
+
* - betterAuth `user` (real id -> identity.subject)
|
|
19
|
+
* - betterAuth `session` (real id -> identity.sessionId, expiresAt future)
|
|
20
|
+
* - app `users` (authId == betterAuth user id)
|
|
21
|
+
*/
|
|
22
|
+
import { ConvexError } from "convex/values";
|
|
23
|
+
import { describe, expect, test } from "vitest";
|
|
24
|
+
|
|
25
|
+
import { api } from "@/convex/_generated/api";
|
|
26
|
+
|
|
27
|
+
import { identityFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
28
|
+
|
|
29
|
+
describe("users.getMe", () => {
|
|
30
|
+
// HAPPY PATH: authed read returns the merged AuthUser reflecting real DB state.
|
|
31
|
+
test("returns the merged user from real seeded rows when authenticated", async () => {
|
|
32
|
+
const t = initConvexTest();
|
|
33
|
+
const { authUserId, sessionId, appUserId, name, email } = await seedAuthedUser(t, {
|
|
34
|
+
name: "Grace Hopper",
|
|
35
|
+
});
|
|
36
|
+
await t.run(async (ctx) => ctx.db.patch(appUserId, { bio: "Compiler pioneer." }));
|
|
37
|
+
|
|
38
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
39
|
+
const me = await asUser.query(api.users.getMe, {});
|
|
40
|
+
|
|
41
|
+
expect(me).not.toBeNull();
|
|
42
|
+
// App-owned fields come from the users row we seeded.
|
|
43
|
+
expect(me!._id).toBe(appUserId);
|
|
44
|
+
expect(me!.authId).toBe(authUserId);
|
|
45
|
+
expect(me!.authUserId).toBe(authUserId);
|
|
46
|
+
expect(me!.bio).toBe("Compiler pioneer.");
|
|
47
|
+
// Identity fields are merged from the Better Auth user record.
|
|
48
|
+
expect(me!.name).toBe(name);
|
|
49
|
+
expect(me!.email).toBe(email);
|
|
50
|
+
expect(me!.emailVerified).toBe(true);
|
|
51
|
+
// No uploaded avatar and no provider image -> resolved avatarUrl is null.
|
|
52
|
+
expect(me!.hasUploadedAvatar).toBe(false);
|
|
53
|
+
expect(me!.avatarUrl).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// GUARD: getMe is optional-auth, so an unauthed call returns null, not a throw,
|
|
57
|
+
// even though the seeded data exists. Proves the auth lookup actually gates the
|
|
58
|
+
// result rather than leaking the row to anonymous callers.
|
|
59
|
+
test("returns null when unauthenticated", async () => {
|
|
60
|
+
const t = initConvexTest();
|
|
61
|
+
await seedAuthedUser(t); // data exists; we just don't attach an identity
|
|
62
|
+
expect(await t.query(api.users.getMe, {})).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// GUARD: the session findOne enforces expiresAt > now. An expired session
|
|
66
|
+
// resolves to no auth user, so getMe falls back to null. This proves the
|
|
67
|
+
// harness traverses the real session lookup, not just identity.subject.
|
|
68
|
+
test("returns null when the session is expired", async () => {
|
|
69
|
+
const t = initConvexTest();
|
|
70
|
+
const { authUserId, sessionId } = await seedAuthedUser(t, {
|
|
71
|
+
expiresAt: Date.now() - 1000, // already expired
|
|
72
|
+
});
|
|
73
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
74
|
+
expect(await asUser.query(api.users.getMe, {})).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// GUARD on the write path that feeds getMe: updateProfile validates bio length
|
|
78
|
+
// (max 500). A 501-char bio throws ConvexError(VAL_3001) and must NOT patch the
|
|
79
|
+
// row, so a follow-up getMe still shows the original bio.
|
|
80
|
+
test("updateProfile rejects an over-long bio and leaves the row unchanged", async () => {
|
|
81
|
+
const t = initConvexTest();
|
|
82
|
+
const { authUserId, sessionId, appUserId } = await seedAuthedUser(t);
|
|
83
|
+
await t.run(async (ctx) => ctx.db.patch(appUserId, { bio: "original" }));
|
|
84
|
+
const asUser = t.withIdentity(identityFor(authUserId, sessionId));
|
|
85
|
+
|
|
86
|
+
const tooLong = "x".repeat(501);
|
|
87
|
+
await expect(asUser.mutation(api.users.updateProfile, { bio: tooLong })).rejects.toThrow(
|
|
88
|
+
ConvexError,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// The patch never ran: the persisted bio is untouched.
|
|
92
|
+
const row = await t.run(async (ctx) => ctx.db.get(appUserId));
|
|
93
|
+
expect(row?.bio).toBe("original");
|
|
94
|
+
// And it surfaces unchanged through the authed read path.
|
|
95
|
+
const me = await asUser.query(api.users.getMe, {});
|
|
96
|
+
expect(me!.bio).toBe("original");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* Real convexTest coverage for `api.users.getUser` (optionalAuthQuery).
|
|
4
|
+
*
|
|
5
|
+
* getUser merges an app `users` row with its Better Auth identity record:
|
|
6
|
+
* 1. ctx.db.normalizeId("users", userId) -> null on malformed input
|
|
7
|
+
* 2. ctx.db.get(id) -> null when the app row is gone
|
|
8
|
+
* 3. authComponent.getAnyUserById(authId) -> null when the auth record is gone
|
|
9
|
+
* 4. returns { _id, _creationTime, name, username, avatarUrl, bio }
|
|
10
|
+
* name/username come from Better Auth; bio from the app row; avatarUrl
|
|
11
|
+
* falls back to the Better Auth `image` when no uploaded avatar exists.
|
|
12
|
+
*
|
|
13
|
+
* Auth seeding uses the shared harness (./_harness): seed a Better Auth `user`
|
|
14
|
+
* + unexpired `session` and the mirrored app `users` row, then patch in the
|
|
15
|
+
* identity fields getUser merges (bio on the app row; image/username/
|
|
16
|
+
* displayUsername on the Better Auth record) using the returned ids.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { api } from "@/convex/_generated/api";
|
|
21
|
+
|
|
22
|
+
import { identityFor, initConvexTest, seedAuthedUser, type AuthedTest } from "./_harness";
|
|
23
|
+
|
|
24
|
+
/** Patch Better Auth identity fields (image/username/displayUsername) onto a seeded user. */
|
|
25
|
+
async function patchAuthFields(
|
|
26
|
+
t: AuthedTest,
|
|
27
|
+
authUserId: string,
|
|
28
|
+
fields: { image?: string; username?: string; displayUsername?: string },
|
|
29
|
+
) {
|
|
30
|
+
await t.runInComponent("betterAuth", async (ctx) => {
|
|
31
|
+
const db = ctx.db as unknown as {
|
|
32
|
+
patch: (id: string, doc: Record<string, unknown>) => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
await db.patch(authUserId, fields);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("api.users.getUser", () => {
|
|
39
|
+
test("returns the merged profile for an existing user (authed caller)", async () => {
|
|
40
|
+
const t = initConvexTest();
|
|
41
|
+
|
|
42
|
+
// Caller is one authed user; they fetch a different target user's profile.
|
|
43
|
+
const caller = await seedAuthedUser(t, {
|
|
44
|
+
name: "Caller One",
|
|
45
|
+
email: "caller@example.com",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const target = await seedAuthedUser(t, {
|
|
49
|
+
name: "Grace Hopper",
|
|
50
|
+
email: "grace@example.com",
|
|
51
|
+
});
|
|
52
|
+
// bio lives on the app row; image/username/displayUsername on the auth record.
|
|
53
|
+
await t.run(async (ctx) => ctx.db.patch(target.appUserId, { bio: "Compiler pioneer." }));
|
|
54
|
+
await patchAuthFields(t, target.authUserId, {
|
|
55
|
+
image: "https://cdn.example.com/grace.png",
|
|
56
|
+
username: "ghopper",
|
|
57
|
+
displayUsername: "GraceH",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const asCaller = t.withIdentity(identityFor(caller.authUserId, caller.sessionId));
|
|
61
|
+
const profile = await asCaller.query(api.users.getUser, {
|
|
62
|
+
userId: target.appUserId,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(profile).not.toBeNull();
|
|
66
|
+
// _id is the app users row id, not the Better Auth id.
|
|
67
|
+
expect(profile!._id).toBe(target.appUserId);
|
|
68
|
+
// name comes from the Better Auth record, not the app row.
|
|
69
|
+
expect(profile!.name).toBe(target.name);
|
|
70
|
+
// username prefers displayUsername over username.
|
|
71
|
+
expect(profile!.username).toBe("GraceH");
|
|
72
|
+
// bio comes from the app users row.
|
|
73
|
+
expect(profile!.bio).toBe("Compiler pioneer.");
|
|
74
|
+
// No uploaded avatar -> falls back to the Better Auth image.
|
|
75
|
+
expect(profile!.avatarUrl).toBe("https://cdn.example.com/grace.png");
|
|
76
|
+
expect(typeof profile!._creationTime).toBe("number");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("username falls back to `username` when displayUsername is absent", async () => {
|
|
80
|
+
const t = initConvexTest();
|
|
81
|
+
const target = await seedAuthedUser(t, {
|
|
82
|
+
name: "No Display Name",
|
|
83
|
+
});
|
|
84
|
+
await patchAuthFields(t, target.authUserId, {
|
|
85
|
+
username: "plainuser",
|
|
86
|
+
// displayUsername intentionally omitted
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const profile = await t.query(api.users.getUser, { userId: target.appUserId });
|
|
90
|
+
expect(profile).not.toBeNull();
|
|
91
|
+
expect(profile!.username).toBe("plainuser");
|
|
92
|
+
// No image and no avatar -> null.
|
|
93
|
+
expect(profile!.avatarUrl).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns null for a malformed user id (normalizeId miss)", async () => {
|
|
97
|
+
const t = initConvexTest();
|
|
98
|
+
// Seed a real user so the table exists, then ask for a garbage id.
|
|
99
|
+
await seedAuthedUser(t);
|
|
100
|
+
const profile = await t.query(api.users.getUser, {
|
|
101
|
+
userId: "not-a-valid-convex-id",
|
|
102
|
+
});
|
|
103
|
+
expect(profile).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("returns null when the app users row was deleted (ctx.db.get miss)", async () => {
|
|
107
|
+
const t = initConvexTest();
|
|
108
|
+
const target = await seedAuthedUser(t);
|
|
109
|
+
|
|
110
|
+
// Capture a well-formed id, then delete the row so normalizeId succeeds
|
|
111
|
+
// but the get() misses. This proves the guard is the missing-row branch,
|
|
112
|
+
// not a malformed-id rejection.
|
|
113
|
+
await t.run(async (ctx) => ctx.db.delete(target.appUserId));
|
|
114
|
+
|
|
115
|
+
const profile = await t.query(api.users.getUser, {
|
|
116
|
+
userId: target.appUserId,
|
|
117
|
+
});
|
|
118
|
+
expect(profile).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* convexTest coverage for `internal.users.hardDeleteExpired`, the irreversible
|
|
4
|
+
* 30-day account purge (the highest-stakes data-loss path in the template).
|
|
5
|
+
*
|
|
6
|
+
* It scans the `by_deletedAt` index for tombstoned users past the grace window
|
|
7
|
+
* and permanently purges them. The index is on the OPTIONAL `deletedAt` field,
|
|
8
|
+
* and Convex sorts `undefined < null < numbers`, so an unbounded scan returns
|
|
9
|
+
* every ACTIVE user (deletedAt unset) before any tombstone. These tests seed
|
|
10
|
+
* MORE active users than the batch size: a regression to an unbounded scan
|
|
11
|
+
* would starve the tombstone and purge nothing, failing the first test.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { internal } from "@/convex/_generated/api";
|
|
16
|
+
import { ACCOUNT_DELETION_GRACE_MS } from "@/convex/users";
|
|
17
|
+
|
|
18
|
+
import { auditRowsFor, initConvexTest, seedAuthedUser } from "./_harness";
|
|
19
|
+
|
|
20
|
+
const HARD_DELETE_BATCH = 50;
|
|
21
|
+
|
|
22
|
+
describe("users.hardDeleteExpired", () => {
|
|
23
|
+
test("purges only tombstones past the grace window, even behind a full batch of active users", async () => {
|
|
24
|
+
const t = initConvexTest();
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
|
|
27
|
+
// Active (deletedAt unset) users filling the batch. Under an unbounded
|
|
28
|
+
// `by_deletedAt` scan these sort ahead of any tombstone and crowd it out.
|
|
29
|
+
await t.run(async (ctx) => {
|
|
30
|
+
for (let i = 0; i < HARD_DELETE_BATCH; i++) {
|
|
31
|
+
await ctx.db.insert("users", { authId: `active_${i}`, createdAt: now, updatedAt: now });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const expired = await seedAuthedUser(t, {
|
|
36
|
+
deletedAt: now - ACCOUNT_DELETION_GRACE_MS - 60_000,
|
|
37
|
+
email: "expired@example.com",
|
|
38
|
+
});
|
|
39
|
+
const inGrace = await seedAuthedUser(t, {
|
|
40
|
+
deletedAt: now - 60_000,
|
|
41
|
+
email: "ingrace@example.com",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const purged = await t.mutation(internal.users.hardDeleteExpired, {});
|
|
45
|
+
expect(purged).toBe(1);
|
|
46
|
+
|
|
47
|
+
// Expired tombstone: a "permanent" audit row was written.
|
|
48
|
+
const expiredAudit = await auditRowsFor(t, expired.appUserId);
|
|
49
|
+
expect(expiredAudit.some((r) => r.event === "permanent")).toBe(true);
|
|
50
|
+
|
|
51
|
+
// In-grace tombstone: untouched, no permanent purge.
|
|
52
|
+
const inGraceRow = await t.run(async (ctx) => ctx.db.get(inGrace.appUserId));
|
|
53
|
+
expect(inGraceRow?.deletedAt).toBe(now - 60_000);
|
|
54
|
+
const inGraceAudit = await auditRowsFor(t, inGrace.appUserId);
|
|
55
|
+
expect(inGraceAudit.some((r) => r.event === "permanent")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("purges nothing when every tombstone is still inside the grace window", async () => {
|
|
59
|
+
const t = initConvexTest();
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
await seedAuthedUser(t, { deletedAt: now - 60_000, email: "recent@example.com" });
|
|
62
|
+
await seedAuthedUser(t, { email: "active@example.com" }); // not tombstoned
|
|
63
|
+
|
|
64
|
+
const purged = await t.mutation(internal.users.hardDeleteExpired, {});
|
|
65
|
+
expect(purged).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
});
|