Skip to content

Commit 2bd4f40

Browse files
Merge pull request #2901 from yashranaway/fix/web-api-parity
fix(web): revalidate Cap pages after mobile password changes
2 parents 4af586c + 2926652 commit 2bd4f40

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { DbClient } from "@cap/web-backend/src/Database";
2+
import type { HttpApi } from "@effect/platform";
3+
import type { Context, Layer as EffectLayer } from "effect";
4+
import {
5+
afterAll,
6+
beforeAll,
7+
beforeEach,
8+
describe,
9+
expect,
10+
it,
11+
vi,
12+
} from "vitest";
13+
14+
const mocks = vi.hoisted(() => ({
15+
ownerId: "owner",
16+
revalidatePath: vi.fn(),
17+
update: vi.fn(),
18+
dispose: async () => {},
19+
}));
20+
21+
vi.mock("server-only", () => ({}));
22+
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
23+
24+
vi.mock("@/lib/server", async () => {
25+
const { Database, Videos } = await import("@cap/web-backend");
26+
const { HttpAuthMiddleware, Organisation, User } = await import(
27+
"@cap/web-domain"
28+
);
29+
const { HttpApiBuilder, HttpServer } = await import("@effect/platform");
30+
const { Effect, Layer, Option } = await import("effect");
31+
const Db = await import("@cap/database/schema");
32+
const { sql } = await import("drizzle-orm");
33+
const summary = {
34+
id: "video",
35+
ownerId: "owner",
36+
name: "Test video",
37+
createdAt: new Date("2026-01-01"),
38+
updatedAt: new Date("2026-01-01"),
39+
ownerName: "Owner",
40+
duration: 10,
41+
folderId: null,
42+
public: true,
43+
hasPassword: false,
44+
commentCount: 0,
45+
reactionCount: 0,
46+
uploadVideoId: null,
47+
uploadUploaded: null,
48+
uploadTotal: null,
49+
uploadPhase: null,
50+
processingProgress: null,
51+
processingMessage: null,
52+
processingError: null,
53+
metadata: null,
54+
transcriptionStatus: null,
55+
};
56+
const client = {
57+
select: (fields: Record<string, unknown>) => {
58+
let table: unknown;
59+
const rows = () => {
60+
if (table === Db.organizations) return [{ id: "org" }];
61+
if (table === Db.users) return [{ preferences: null }];
62+
if (table === Db.videos && "ownerPreferences" in fields) {
63+
return [
64+
{
65+
ownerId: mocks.ownerId,
66+
ownerPreferences: null,
67+
hasPassword: false,
68+
hasInheritedPassword: false,
69+
},
70+
];
71+
}
72+
if (table === Db.videos)
73+
return [{ ...summary, ownerId: mocks.ownerId }];
74+
return [];
75+
};
76+
const query = {
77+
from: (value: unknown) => {
78+
table = value;
79+
return query;
80+
},
81+
leftJoin: () => query,
82+
innerJoin: () => query,
83+
where: () => query,
84+
limit: async () => rows(),
85+
groupBy: async () => rows(),
86+
getSQL: () => sql`SELECT 1`,
87+
};
88+
return query;
89+
},
90+
update: () => ({
91+
set: (value: unknown) => ({
92+
where: async () => {
93+
mocks.update(value);
94+
},
95+
}),
96+
}),
97+
};
98+
const database = Layer.succeed(
99+
Database,
100+
Database.make({
101+
use: (callback) =>
102+
Effect.promise(() => callback(client as unknown as DbClient)),
103+
}),
104+
);
105+
const videos = Layer.succeed(Videos, {
106+
getAnalytics: () => Effect.succeed({ count: 0 }),
107+
} as unknown as Context.Tag.Service<typeof Videos>);
108+
const auth = Layer.succeed(
109+
HttpAuthMiddleware,
110+
Effect.succeed({
111+
id: User.UserId.make("owner"),
112+
email: "owner@example.com",
113+
activeOrganizationId: Organisation.OrganisationId.make("org"),
114+
iconUrlOrKey: Option.none(),
115+
}),
116+
);
117+
return {
118+
apiToHandler: (
119+
api: EffectLayer.Layer<
120+
HttpApi.Api,
121+
never,
122+
| Context.Tag.Identifier<typeof Database>
123+
| Context.Tag.Identifier<typeof Videos>
124+
| Context.Tag.Identifier<typeof HttpAuthMiddleware>
125+
>,
126+
) => {
127+
const handler = api.pipe(
128+
Layer.provideMerge(auth),
129+
Layer.provideMerge(database),
130+
Layer.provideMerge(videos),
131+
Layer.merge(HttpServer.layerContext),
132+
HttpApiBuilder.toWebHandler,
133+
);
134+
mocks.dispose = handler.dispose;
135+
return handler.handler;
136+
},
137+
};
138+
});
139+
140+
describe("mobile Cap password update", () => {
141+
let PATCH: typeof import("@/app/api/mobile/[...route]/route").PATCH;
142+
143+
beforeAll(async () => {
144+
Object.assign(process.env, {
145+
NEXT_PUBLIC_WEB_URL: "http://localhost",
146+
WEB_URL: "http://localhost",
147+
DATABASE_URL: "mysql://unused:unused@127.0.0.1:3306/unused",
148+
NEXTAUTH_SECRET: "synthetic-test-secret-that-is-long-enough",
149+
NEXTAUTH_URL: "http://localhost",
150+
CAP_AWS_BUCKET: "synthetic",
151+
CAP_AWS_REGION: "us-east-1",
152+
CAP_AWS_ACCESS_KEY: "synthetic",
153+
CAP_AWS_SECRET_KEY: "synthetic",
154+
NODE_ENV: "test",
155+
});
156+
({ PATCH } = await import("@/app/api/mobile/[...route]/route"));
157+
});
158+
159+
beforeEach(() => {
160+
mocks.ownerId = "owner";
161+
});
162+
163+
afterAll(() => mocks.dispose());
164+
165+
const request = () =>
166+
PATCH(
167+
new Request("http://localhost/api/mobile/caps/video/password", {
168+
method: "PATCH",
169+
headers: { "Content-Type": "application/json" },
170+
body: JSON.stringify({ password: null }),
171+
}),
172+
);
173+
174+
it("revalidates the share page after removing its password", async () => {
175+
expect((await request()).status).toBe(200);
176+
expect(mocks.update).toHaveBeenCalledWith({ password: null });
177+
expect(mocks.revalidatePath.mock.calls.map(([path]) => path)).toEqual([
178+
"/dashboard/caps",
179+
"/dashboard/shared-caps",
180+
"/s/video",
181+
]);
182+
});
183+
184+
it("does not revalidate when the caller does not own the Cap", async () => {
185+
mocks.ownerId = "someone-else";
186+
expect((await request()).status).toBe(404);
187+
expect(mocks.update).not.toHaveBeenCalled();
188+
expect(mocks.revalidatePath).not.toHaveBeenCalled();
189+
});
190+
});

‎apps/web/app/api/mobile/[...route]/route.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2989,6 +2989,11 @@ const ApiLive = HttpApiBuilder.api(Mobile.MobileApiContract).pipe(
29892989
),
29902990
),
29912991
);
2992+
yield* Effect.sync(() => {
2993+
revalidatePath("/dashboard/caps");
2994+
revalidatePath("/dashboard/shared-caps");
2995+
revalidatePath(`/s/${path.id}`);
2996+
});
29922997
const { cap } = yield* getCapById(path.id, publicOrigin);
29932998
return cap;
29942999
}),

0 commit comments

Comments
 (0)