Perplexed7675 commited on
Commit
3bd91cd
·
verified ·
1 Parent(s): 9efde37

Sync from kink_cli (Docker Space)

Browse files
api.py CHANGED
@@ -31,6 +31,15 @@ _MEDIA_CACHE = "public, max-age=86400, immutable"
31
  _MEDIA_FALLBACK_CACHE = "public, max-age=3600"
32
 
33
 
 
 
 
 
 
 
 
 
 
34
  def _hf_space_published_image() -> bool:
35
  """True in the Hugging Face Space Docker image (``Dockerfile`` sets ``KINK_HF_SPACE_IMAGE=1``).
36
 
@@ -194,6 +203,9 @@ async def add_security_headers(request: Request, call_next):
194
  response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
195
  response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
196
  response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
 
 
 
197
  return response
198
 
199
 
@@ -359,7 +371,7 @@ def frontend_static(filepath: str) -> FileResponse:
359
  raise HTTPException(status_code=404, detail="Not found")
360
  if not target.exists() or not target.is_file():
361
  raise HTTPException(status_code=404, detail="Not found")
362
- cache_control = "public, max-age=300, stale-while-revalidate=60" if target.suffix in {".js", ".css"} else "no-store"
363
  return FileResponse(target, headers={"Cache-Control": cache_control})
364
 
365
 
 
31
  _MEDIA_FALLBACK_CACHE = "public, max-age=3600"
32
 
33
 
34
+ def _frontend_static_cache_control(target: Path) -> str:
35
+ """Browser e2e and local iteration can set ``KINK_FRONTEND_NO_CACHE=1`` so module graphs always match disk."""
36
+ if os.environ.get("KINK_FRONTEND_NO_CACHE") == "1":
37
+ return "no-store"
38
+ if target.suffix in {".js", ".css"}:
39
+ return "public, max-age=300, stale-while-revalidate=60"
40
+ return "no-store"
41
+
42
+
43
  def _hf_space_published_image() -> bool:
44
  """True in the Hugging Face Space Docker image (``Dockerfile`` sets ``KINK_HF_SPACE_IMAGE=1``).
45
 
 
203
  response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
204
  response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
205
  response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
206
+ # Personalized GETs must not be cached by intermediaries or the browser (Playwright e2e saw stale ``/users/…`` payloads).
207
+ if request.method == "GET" and request.url.path.startswith("/users/"):
208
+ response.headers["Cache-Control"] = "private, no-store"
209
  return response
210
 
211
 
 
371
  raise HTTPException(status_code=404, detail="Not found")
372
  if not target.exists() or not target.is_file():
373
  raise HTTPException(status_code=404, detail="Not found")
374
+ cache_control = _frontend_static_cache_control(target)
375
  return FileResponse(target, headers={"Cache-Control": cache_control})
376
 
377
 
frontend/app.js CHANGED
@@ -15,7 +15,7 @@ import {
15
  useGroupsQuery,
16
  useRolesQuery,
17
  useRoleSearchQuery,
18
- } from "./queries.js?v=38";
19
  import {
20
  useLoginMutation,
21
  useCreateMutation,
@@ -28,9 +28,9 @@ import {
28
  useRemoveGroupMemberMutation,
29
  useShareToggleMutation,
30
  usePartnerRequestMutations,
31
- } from "./mutations.js?v=38";
32
- import { optimisticBoard, removeKinkFromItemsPayload, playCommitKey } from "./board-utils.js?v=32";
33
- import { DiscoverView } from "./discover.js?v=47";
34
  import { normalizeDiscoverSheetDirections } from "./discover-flow.js?v=1";
35
  import { ScenariosView } from "./scenarios.js?v=1";
36
  import { MyPlaysView } from "./my-plays.js?v=37";
@@ -191,8 +191,11 @@ function AppInner() {
191
  starterCompleted,
192
  });
193
  const needsStarterFlow = starterState === "starter" || starterState === "finalizing";
 
 
194
 
195
  useEffect(() => {
 
196
  if (!auth.userId || !auth.token) return;
197
  if (!userQuery.isError || userQuery.isFetching) return;
198
  const statusCode = userQuery.error?.status;
@@ -200,9 +203,10 @@ function AppInner() {
200
  setStatus("That profile is gone. Create a new one or log in again.");
201
  logout();
202
  }
203
- }, [auth.userId, auth.token, userQuery.isError, userQuery.isFetching, userQuery.error, logout]);
204
 
205
  useEffect(() => {
 
206
  if (!auth.userId || !auth.token) return;
207
  const bad = (q) => q.isError && !q.isFetching && (q.error?.status === 401 || q.error?.status === 404);
208
  if (bad(groupsQuery) || bad(rolesQuery)) {
@@ -210,6 +214,7 @@ function AppInner() {
210
  logout();
211
  }
212
  }, [
 
213
  auth.userId,
214
  auth.token,
215
  groupsQuery.isError,
@@ -274,7 +279,7 @@ function AppInner() {
274
  function syncPostSaveCaches(userPayload, kinkId, interestState, directions) {
275
  qc.setQueryData(["user", auth.userId], userPayload);
276
  qc.setQueryData(["board", auth.userId], (old) => optimisticBoard(old || EMPTY_BOARD, kinkId, interestState, directions, qc, auth));
277
- qc.setQueryData(["recs", auth.userId, activeGroupId], (old) => removeKinkFromItemsPayload(old, kinkId));
278
  qc.invalidateQueries({ queryKey: ["scenario-parents", auth.userId] });
279
  if (activeGroupId) qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, activeGroupId] });
280
  }
@@ -283,7 +288,7 @@ function AppInner() {
283
  const createMutation = useCreateMutation({ setAuth, setCreateResult, setLoginForm, setStatus });
284
  const savePlayMutation = useSavePlayMutation({
285
  auth,
286
- activeGroupId,
287
  selectedKinkId: "",
288
  setSelectedKinkId: () => {},
289
  setStatus,
@@ -351,16 +356,23 @@ function AppInner() {
351
  }, []);
352
 
353
  const handleRate = useCallback((kinkId, interestState, directions = null) => {
354
- const prevState = currentPlays[kinkId] || null;
355
- const nextDirections = normalizeDirectionsForSave(interestState, directions ?? currentDirectionsFor(currentPlays, kinkId));
 
356
  const normalized = [...nextDirections].sort();
357
  const existing = [...(prevState?.directions || [])].sort();
358
- if (prevState?.interest_state === interestState && JSON.stringify(existing) === JSON.stringify(normalized)) return Promise.resolve(null);
359
- const commitKey = playCommitKey(kinkId, interestState, normalized);
360
- if (pendingPlayCommitRef.current === commitKey) return Promise.resolve(null);
 
 
 
 
 
 
361
  pendingPlayCommitRef.current = commitKey;
362
- return savePlayMutation.mutateAsync({ kinkId, interestState, directions: normalized });
363
- }, [currentPlays, discoverPositiveDirections, savePlayMutation]);
364
 
365
  const handleDirectionToggle = useCallback((kinkId, direction) => {
366
  const play = currentPlays[kinkId];
@@ -446,8 +458,14 @@ function AppInner() {
446
  selectedRoles=${selectedRoles}
447
  pendingPartnerId=${pendingPartnerId}
448
  onFinish=${() => {
 
449
  setCreateResult(null);
450
  setAuthStep(null);
 
 
 
 
 
451
  }}
452
  />
453
  `;
@@ -746,7 +764,15 @@ function Onboarding({
746
  <p className="sub">Discover what you like. See what you share. Keep private things private by default.</p>
747
  <div className="card stack">
748
  ${pendingPartnerId ? html`<div className="tiny">You were invited by <code>${pendingPartnerId}</code>. Create or open a profile and we’ll stage the connection for you.</div>` : null}
749
- <button data-testid="onboarding-create-profile" onClick=${() => createMutation.mutate()}>Create profile</button>
 
 
 
 
 
 
 
 
750
  <button className="ghost" data-testid="onboarding-have-profile" onClick=${() => setStep("login")}>I have a profile</button>
751
  </div>
752
  <${StatusText} text=${status} />
 
15
  useGroupsQuery,
16
  useRolesQuery,
17
  useRoleSearchQuery,
18
+ } from "./queries.js?v=40";
19
  import {
20
  useLoginMutation,
21
  useCreateMutation,
 
28
  useRemoveGroupMemberMutation,
29
  useShareToggleMutation,
30
  usePartnerRequestMutations,
31
+ } from "./mutations.js?v=52";
32
+ import { optimisticBoard, removeKinkFromItemsPayload, playCommitKey } from "./board-utils.js?v=33";
33
+ import { DiscoverView } from "./discover.js?v=64";
34
  import { normalizeDiscoverSheetDirections } from "./discover-flow.js?v=1";
35
  import { ScenariosView } from "./scenarios.js?v=1";
36
  import { MyPlaysView } from "./my-plays.js?v=37";
 
191
  starterCompleted,
192
  });
193
  const needsStarterFlow = starterState === "starter" || starterState === "finalizing";
194
+ /** Starter Discover always queries recs with group id ""; keep save/recs cache in sync if partner groups load mid-flow. */
195
+ const recsCacheGroupId = needsStarterFlow ? "" : activeGroupId;
196
 
197
  useEffect(() => {
198
+ if (createResult) return;
199
  if (!auth.userId || !auth.token) return;
200
  if (!userQuery.isError || userQuery.isFetching) return;
201
  const statusCode = userQuery.error?.status;
 
203
  setStatus("That profile is gone. Create a new one or log in again.");
204
  logout();
205
  }
206
+ }, [createResult, auth.userId, auth.token, userQuery.isError, userQuery.isFetching, userQuery.error, logout]);
207
 
208
  useEffect(() => {
209
+ if (createResult) return;
210
  if (!auth.userId || !auth.token) return;
211
  const bad = (q) => q.isError && !q.isFetching && (q.error?.status === 401 || q.error?.status === 404);
212
  if (bad(groupsQuery) || bad(rolesQuery)) {
 
214
  logout();
215
  }
216
  }, [
217
+ createResult,
218
  auth.userId,
219
  auth.token,
220
  groupsQuery.isError,
 
279
  function syncPostSaveCaches(userPayload, kinkId, interestState, directions) {
280
  qc.setQueryData(["user", auth.userId], userPayload);
281
  qc.setQueryData(["board", auth.userId], (old) => optimisticBoard(old || EMPTY_BOARD, kinkId, interestState, directions, qc, auth));
282
+ qc.setQueryData(["recs", auth.userId, recsCacheGroupId], (old) => removeKinkFromItemsPayload(old, kinkId));
283
  qc.invalidateQueries({ queryKey: ["scenario-parents", auth.userId] });
284
  if (activeGroupId) qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, activeGroupId] });
285
  }
 
288
  const createMutation = useCreateMutation({ setAuth, setCreateResult, setLoginForm, setStatus });
289
  const savePlayMutation = useSavePlayMutation({
290
  auth,
291
+ activeGroupId: recsCacheGroupId,
292
  selectedKinkId: "",
293
  setSelectedKinkId: () => {},
294
  setStatus,
 
356
  }, []);
357
 
358
  const handleRate = useCallback((kinkId, interestState, directions = null) => {
359
+ const kid = String(kinkId);
360
+ const prevState = currentPlays[kid] || null;
361
+ const nextDirections = normalizeDirectionsForSave(interestState, directions ?? currentDirectionsFor(currentPlays, kid));
362
  const normalized = [...nextDirections].sort();
363
  const existing = [...(prevState?.directions || [])].sort();
364
+ if (
365
+ !needsStarterFlow
366
+ && prevState?.interest_state === interestState
367
+ && JSON.stringify(existing) === JSON.stringify(normalized)
368
+ ) {
369
+ return Promise.resolve(null);
370
+ }
371
+ const commitKey = playCommitKey(kid, interestState, normalized);
372
+ if (!needsStarterFlow && pendingPlayCommitRef.current === commitKey) return Promise.resolve(null);
373
  pendingPlayCommitRef.current = commitKey;
374
+ return savePlayMutation.mutateAsync({ kinkId: kid, interestState, directions: normalized });
375
+ }, [currentPlays, discoverPositiveDirections, needsStarterFlow, savePlayMutation]);
376
 
377
  const handleDirectionToggle = useCallback((kinkId, direction) => {
378
  const play = currentPlays[kinkId];
 
458
  selectedRoles=${selectedRoles}
459
  pendingPartnerId=${pendingPartnerId}
460
  onFinish=${() => {
461
+ const uid = auth.userId;
462
  setCreateResult(null);
463
  setAuthStep(null);
464
+ if (uid) {
465
+ qc.invalidateQueries({ queryKey: ["user", uid] });
466
+ qc.invalidateQueries({ queryKey: ["partner-groups", uid] });
467
+ qc.invalidateQueries({ queryKey: ["roles", uid] });
468
+ }
469
  }}
470
  />
471
  `;
 
764
  <p className="sub">Discover what you like. See what you share. Keep private things private by default.</p>
765
  <div className="card stack">
766
  ${pendingPartnerId ? html`<div className="tiny">You were invited by <code>${pendingPartnerId}</code>. Create or open a profile and we’ll stage the connection for you.</div>` : null}
767
+ <button
768
+ data-testid="onboarding-create-profile"
769
+ disabled=${createMutation.isPending}
770
+ onClick=${() => {
771
+ if (!createMutation.isPending) createMutation.mutate();
772
+ }}
773
+ >
774
+ ${createMutation.isPending ? "Creating profile…" : "Create profile"}
775
+ </button>
776
  <button className="ghost" data-testid="onboarding-have-profile" onClick=${() => setStep("login")}>I have a profile</button>
777
  </div>
778
  <${StatusText} text=${status} />
frontend/board-utils.js CHANGED
@@ -7,17 +7,18 @@ export function sortedBoardItems(items) {
7
  }
8
 
9
  export function optimisticBoard(previousBoard, kinkId, interestState, directions, qc, auth) {
 
10
  const nextBoard = Object.fromEntries(
11
  Object.entries(previousBoard || EMPTY_BOARD).map(([key, items]) => [
12
  key,
13
- (items || []).filter((item) => item.id !== kinkId),
14
  ]),
15
  );
16
 
17
  const sourceItem =
18
- findBoardItemInData(previousBoard, kinkId) || (qc ? findBoardItemFromQuery(qc, auth, kinkId) : null);
19
  /** If play-board has not loaded yet (or kink not in recs cache), still place a stub so we never strip a row and return empty buckets. */
20
- const itemBase = sourceItem || { id: kinkId, name: kinkId, summary: "", popularity: 0 };
21
  const item = { ...itemBase, interest_state: interestState, directions };
22
  if (interestState === "hard_no") {
23
  nextBoard.no_go.push(item);
@@ -34,8 +35,9 @@ export function optimisticBoard(previousBoard, kinkId, interestState, directions
34
  }
35
 
36
  export function findBoardItemInData(board, kinkId) {
 
37
  for (const items of Object.values(board || EMPTY_BOARD)) {
38
- const match = (items || []).find((item) => item.id === kinkId);
39
  if (match) return match;
40
  }
41
  return null;
@@ -52,7 +54,8 @@ function normalizeKinkItem(raw) {
52
  }
53
 
54
  function findInItemsPayload(payload, kinkId) {
55
- const match = payload?.items?.find((item) => item?.kink?.id === kinkId);
 
56
  return normalizeKinkItem(match?.kink || null);
57
  }
58
 
@@ -95,9 +98,10 @@ function findBoardItemFromQuery(qc, auth, kinkId) {
95
 
96
  export function removeKinkFromItemsPayload(payload, kinkId) {
97
  if (!payload?.items) return payload;
 
98
  return {
99
  ...payload,
100
- items: payload.items.filter((item) => item?.kink?.id !== kinkId),
101
  };
102
  }
103
 
 
7
  }
8
 
9
  export function optimisticBoard(previousBoard, kinkId, interestState, directions, qc, auth) {
10
+ const kid = String(kinkId);
11
  const nextBoard = Object.fromEntries(
12
  Object.entries(previousBoard || EMPTY_BOARD).map(([key, items]) => [
13
  key,
14
+ (items || []).filter((item) => String(item.id) !== kid),
15
  ]),
16
  );
17
 
18
  const sourceItem =
19
+ findBoardItemInData(previousBoard, kid) || (qc ? findBoardItemFromQuery(qc, auth, kid) : null);
20
  /** If play-board has not loaded yet (or kink not in recs cache), still place a stub so we never strip a row and return empty buckets. */
21
+ const itemBase = sourceItem || { id: kid, name: kid, summary: "", popularity: 0 };
22
  const item = { ...itemBase, interest_state: interestState, directions };
23
  if (interestState === "hard_no") {
24
  nextBoard.no_go.push(item);
 
35
  }
36
 
37
  export function findBoardItemInData(board, kinkId) {
38
+ const kid = String(kinkId);
39
  for (const items of Object.values(board || EMPTY_BOARD)) {
40
+ const match = (items || []).find((item) => String(item.id) === kid);
41
  if (match) return match;
42
  }
43
  return null;
 
54
  }
55
 
56
  function findInItemsPayload(payload, kinkId) {
57
+ const kid = String(kinkId);
58
+ const match = payload?.items?.find((item) => String(item?.kink?.id) === kid);
59
  return normalizeKinkItem(match?.kink || null);
60
  }
61
 
 
98
 
99
  export function removeKinkFromItemsPayload(payload, kinkId) {
100
  if (!payload?.items) return payload;
101
+ const kid = String(kinkId);
102
  return {
103
  ...payload,
104
+ items: payload.items.filter((item) => String(item?.kink?.id) !== kid),
105
  };
106
  }
107
 
frontend/discover-state.js CHANGED
@@ -4,8 +4,10 @@ export function visibleDiscoveryItems(rawItems, currentPlays) {
4
  const out = [];
5
  for (const row of rawItems || []) {
6
  const id = row?.kink?.id;
7
- if (!id || played[id] || seen.has(id)) continue;
8
- seen.add(id);
 
 
9
  out.push(row);
10
  }
11
  return out;
 
4
  const out = [];
5
  for (const row of rawItems || []) {
6
  const id = row?.kink?.id;
7
+ if (id == null || id === "") continue;
8
+ const sid = String(id);
9
+ if (played[sid] || seen.has(sid)) continue;
10
+ seen.add(sid);
11
  out.push(row);
12
  }
13
  return out;
frontend/discover-state.test.mjs CHANGED
@@ -3,6 +3,15 @@ import test from "node:test";
3
 
4
  import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js";
5
 
 
 
 
 
 
 
 
 
 
6
  test("visibleDiscoveryItems drops already-rated raw recommendation rows", () => {
7
  const rawItems = [
8
  { kink: { id: "a" } },
 
3
 
4
  import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js";
5
 
6
+ test("visibleDiscoveryItems treats numeric kink ids and string play keys as the same id", () => {
7
+ const rawItems = [{ kink: { id: 12, name: "one" } }, { kink: { id: 34, name: "two" } }];
8
+ const currentPlays = { "12": { interest_state: "like", directions: ["together"] } };
9
+ assert.deepEqual(
10
+ visibleDiscoveryItems(rawItems, currentPlays).map((row) => row.kink.id),
11
+ [34],
12
+ );
13
+ });
14
+
15
  test("visibleDiscoveryItems drops already-rated raw recommendation rows", () => {
16
  const rawItems = [
17
  { kink: { id: "a" } },
frontend/discover.js CHANGED
@@ -3,8 +3,8 @@ import { useQueryClient } from "https://esm.sh/@tanstack/react-query@5.59.20?dep
3
  import { html, partnerGroupChipLabel, partnerGroupPartnerIdsTitle, DIRECTION_PILLS } from "./constants.js?v=33";
4
  import { KinkCard, ActionBar, SearchOverlay } from "./components.js?v=41";
5
  import { normalizeDiscoverSheetDirections, applyDiscoverDirectionChoice } from "./discover-flow.js?v=1";
6
- import { useRecsQuery, useSearchQuery, useDetailQuery } from "./queries.js?v=38";
7
- import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js?v=32";
8
  import { api } from "./api.js?v=33";
9
  import { sendTelemetry } from "./telemetry.js?v=33";
10
 
@@ -110,12 +110,13 @@ export function DiscoverView({
110
 
111
  const safeIndex = items.length ? Math.min(cardIndex, items.length - 1) : 0;
112
  const currentItem = items[safeIndex] || null;
113
- const kinkId = currentItem?.kink?.id || "";
 
114
  const detailQuery = useDetailQuery(kinkId);
115
  const kink = useMemo(() => {
116
  const listKink = currentItem?.kink;
117
  const detail = detailQuery.data;
118
- const detailOk = detail && detail.id === kinkId ? detail : null;
119
  if (!listKink && !detailOk) return null;
120
  if (detailOk && listKink && listKink.id === detailOk.id) return { ...listKink, ...detailOk };
121
  return listKink || detailOk;
 
3
  import { html, partnerGroupChipLabel, partnerGroupPartnerIdsTitle, DIRECTION_PILLS } from "./constants.js?v=33";
4
  import { KinkCard, ActionBar, SearchOverlay } from "./components.js?v=41";
5
  import { normalizeDiscoverSheetDirections, applyDiscoverDirectionChoice } from "./discover-flow.js?v=1";
6
+ import { useRecsQuery, useSearchQuery, useDetailQuery } from "./queries.js?v=40";
7
+ import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js?v=33";
8
  import { api } from "./api.js?v=33";
9
  import { sendTelemetry } from "./telemetry.js?v=33";
10
 
 
110
 
111
  const safeIndex = items.length ? Math.min(cardIndex, items.length - 1) : 0;
112
  const currentItem = items[safeIndex] || null;
113
+ const rawKinkId = currentItem?.kink?.id;
114
+ const kinkId = rawKinkId == null || rawKinkId === "" ? "" : String(rawKinkId);
115
  const detailQuery = useDetailQuery(kinkId);
116
  const kink = useMemo(() => {
117
  const listKink = currentItem?.kink;
118
  const detail = detailQuery.data;
119
+ const detailOk = detail && String(detail.id) === kinkId ? detail : null;
120
  if (!listKink && !detailOk) return null;
121
  if (detailOk && listKink && listKink.id === detailOk.id) return { ...listKink, ...detailOk };
122
  return listKink || detailOk;
frontend/index.html CHANGED
@@ -11,6 +11,6 @@
11
  </head>
12
  <body>
13
  <div id="root"></div>
14
- <script type="module" src="/frontend/app.js?v=49"></script>
15
  </body>
16
  </html>
 
11
  </head>
12
  <body>
13
  <div id="root"></div>
14
+ <script type="module" src="/frontend/app.js?v=78"></script>
15
  </body>
16
  </html>
frontend/mutations.js CHANGED
@@ -1,6 +1,6 @@
1
  import { useMutation, useQueryClient } from "https://esm.sh/@tanstack/react-query@5.59.20?deps=react@18.3.1";
2
  import { api, authHeaders } from "./api.js?v=33";
3
- import { optimisticBoard, removeKinkFromItemsPayload } from "./board-utils.js?v=32";
4
 
5
  const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
6
 
@@ -32,6 +32,11 @@ export function useCreateMutation({ setAuth, setCreateResult, setLoginForm, setS
32
  headers: { "Content-Type": "application/json" },
33
  body: "{}",
34
  }),
 
 
 
 
 
35
  onSuccess: (payload) => {
36
  setAuth({ userId: payload.id, token: payload.private_token });
37
  setCreateResult(payload);
@@ -66,38 +71,42 @@ export function useSavePlayMutation({
66
  onMutate: async (vars) => {
67
  onSaveStart();
68
  const directions = vars.directions || [];
69
- await qc.cancelQueries({ queryKey: ["user", auth.userId] });
 
 
70
  const recsKey = ["recs", auth.userId, activeGroupId];
71
  const previousUser = qc.getQueryData(["user", auth.userId]);
72
  const previousBoard = qc.getQueryData(["board", auth.userId]);
73
  const previousRecs = qc.getQueryData(recsKey);
 
 
 
 
 
 
 
 
 
 
 
74
  if (previousRecs?.items) {
75
- qc.setQueryData(recsKey, (old) => removeKinkFromItemsPayload(old, vars.kinkId));
76
  }
77
- qc.setQueryData(["user", auth.userId], (old) =>
78
- old
79
- ? {
80
- ...old,
81
- plays: {
82
- ...(old.plays || {}),
83
- [vars.kinkId]: { interest_state: vars.interestState, directions },
84
- },
85
- }
86
- : old,
87
  );
88
- qc.setQueryData(["board", auth.userId], (old) => optimisticBoard(old || previousBoard || EMPTY_BOARD, vars.kinkId, vars.interestState, directions, qc, auth));
89
- if (!selectedKinkId) setSelectedKinkId(vars.kinkId);
90
  setStatus("Saving...");
91
  return {
92
  previousUser,
93
  previousBoard,
94
  previousRecs,
95
  recsKey,
96
- commitKey: playCommitKey(vars.kinkId, vars.interestState, directions),
97
  };
98
  },
99
  onSuccess: (payload, vars, context) => {
100
- syncPostSaveCaches(payload, vars.kinkId, vars.interestState, vars.directions || []);
101
  /** Refetch play-board from server so My Plays never stays on a partial optimistic board (e.g. board cache was empty when save ran). */
102
  qc.invalidateQueries({ queryKey: ["board", auth.userId] });
103
  /** Avoid immediate recs/prompts refetch: it re-randomized the deck and made the next card “flash” away. Server still updates user/board via payload + optimistic cache. */
 
1
  import { useMutation, useQueryClient } from "https://esm.sh/@tanstack/react-query@5.59.20?deps=react@18.3.1";
2
  import { api, authHeaders } from "./api.js?v=33";
3
+ import { optimisticBoard, removeKinkFromItemsPayload } from "./board-utils.js?v=33";
4
 
5
  const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
6
 
 
32
  headers: { "Content-Type": "application/json" },
33
  body: "{}",
34
  }),
35
+ onMutate: () => {
36
+ setStatus(
37
+ "Creating profile… If this sits here a while, the Space is still loading the catalog (first boot can take several minutes).",
38
+ );
39
+ },
40
  onSuccess: (payload) => {
41
  setAuth({ userId: payload.id, token: payload.private_token });
42
  setCreateResult(payload);
 
71
  onMutate: async (vars) => {
72
  onSaveStart();
73
  const directions = vars.directions || [];
74
+ /** Do not cancel/refetch the user query here: refetchInterval can still deliver a stale GET that
75
+ * replaces the cache after this onMutate runs but before onSuccess, wiping the optimistic play
76
+ * and leaving the same top card so the next tap dedupes to “no new save” while the counter stays flat. */
77
  const recsKey = ["recs", auth.userId, activeGroupId];
78
  const previousUser = qc.getQueryData(["user", auth.userId]);
79
  const previousBoard = qc.getQueryData(["board", auth.userId]);
80
  const previousRecs = qc.getQueryData(recsKey);
81
+ const playKey = String(vars.kinkId);
82
+ qc.setQueryData(["user", auth.userId], (old) => {
83
+ const base = old && typeof old === "object" ? old : { id: auth.userId, plays: {} };
84
+ return {
85
+ ...base,
86
+ plays: {
87
+ ...(base.plays || {}),
88
+ [playKey]: { interest_state: vars.interestState, directions },
89
+ },
90
+ };
91
+ });
92
  if (previousRecs?.items) {
93
+ qc.setQueryData(recsKey, (old) => removeKinkFromItemsPayload(old, playKey));
94
  }
95
+ qc.setQueryData(["board", auth.userId], (old) =>
96
+ optimisticBoard(old || previousBoard || EMPTY_BOARD, playKey, vars.interestState, directions, qc, auth),
 
 
 
 
 
 
 
 
97
  );
98
+ if (!selectedKinkId) setSelectedKinkId(playKey);
 
99
  setStatus("Saving...");
100
  return {
101
  previousUser,
102
  previousBoard,
103
  previousRecs,
104
  recsKey,
105
+ commitKey: playCommitKey(playKey, vars.interestState, directions),
106
  };
107
  },
108
  onSuccess: (payload, vars, context) => {
109
+ syncPostSaveCaches(payload, String(vars.kinkId), vars.interestState, vars.directions || []);
110
  /** Refetch play-board from server so My Plays never stays on a partial optimistic board (e.g. board cache was empty when save ran). */
111
  qc.invalidateQueries({ queryKey: ["board", auth.userId] });
112
  /** Avoid immediate recs/prompts refetch: it re-randomized the deck and made the next card “flash” away. Server still updates user/board via payload + optimistic cache. */
scripts/playwright_product_flow.py CHANGED
@@ -2,17 +2,24 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
5
  import time
6
  import urllib.parse
7
  import urllib.request
8
  from dataclasses import asdict, dataclass
9
  from typing import Callable
10
 
11
- from playwright.sync_api import Browser, BrowserContext, Page, expect, sync_playwright
12
 
13
 
14
  BASE_URL = os.environ.get("KINK_AUDIT_BASE_URL", "http://127.0.0.1:8012").rstrip("/")
15
- DEFAULT_SCENARIOS = ["starter", "settings", "couple"]
 
 
 
 
 
 
16
 
17
 
18
  @dataclass
@@ -29,7 +36,9 @@ class StageResult:
29
 
30
 
31
  def _json_request(path: str, *, method: str = "GET", payload: dict | None = None, headers: dict[str, str] | None = None, timeout: int = 60) -> dict:
32
- request_headers = {"Content-Type": "text/plain;charset=UTF-8"}
 
 
33
  if headers:
34
  request_headers.update(headers)
35
  request = urllib.request.Request(
@@ -184,13 +193,29 @@ def ensure_play_absent(creds: Credentials, kink_id: str) -> None:
184
 
185
 
186
  def open_app(page: Page, *, path: str = "") -> None:
187
- page.goto(f"{BASE_URL}{path}", wait_until="domcontentloaded")
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
 
190
  def create_profile_in_browser(page: Page, *, path: str = "") -> Credentials:
191
  open_app(page, path=path)
192
- page.get_by_test_id("onboarding-create-profile").click()
193
- expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=15000)
 
 
 
194
  creds = Credentials(
195
  user_id=page.get_by_test_id("onboarding-created-user-id").inner_text().strip(),
196
  private_token=page.get_by_test_id("onboarding-created-private-token").inner_text().strip(),
@@ -210,13 +235,48 @@ def login_existing_user(page: Page, creds: Credentials) -> None:
210
  page.get_by_test_id("onboarding-open-profile").click()
211
 
212
 
213
- def finish_starter_gate(page: Page, *, positive_action: str = "like", total_actions: int = 12) -> None:
214
- expect(page.get_by_test_id("starter-gate")).to_be_visible(timeout=15000)
215
- page.get_by_test_id(f"discover-rate-{positive_action}").click()
216
- for _ in range(total_actions - 1):
217
- page.get_by_test_id("discover-rate-not_interested").click()
218
- expect(page.get_by_test_id("nav-tab-discover")).to_be_visible(timeout=15000)
219
- expect(page.get_by_test_id("discover-view")).to_be_visible(timeout=15000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
 
222
  def open_together(page: Page) -> None:
@@ -248,7 +308,7 @@ def wait_for(predicate: Callable[[], bool], *, timeout_s: float = 20.0, step_s:
248
  while time.time() < deadline:
249
  if predicate():
250
  return
251
- time.sleep(step_s)
252
  raise AssertionError(f"Timed out waiting for {label}")
253
 
254
 
@@ -271,18 +331,74 @@ def _run_stage(name: str, fn: Callable[[], dict[str, object]]) -> StageResult:
271
  return StageResult(name=name, ok=False, details={"error": str(exc)})
272
 
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  def scenario_starter_gate(page: Page) -> dict[str, object]:
275
- creds = create_profile_in_browser(page)
276
- finish_starter_gate(page)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  user = get_user(creds)
278
  saved_count = len(user.get("plays", {}))
279
  if saved_count < 12:
280
  raise AssertionError(f"starter gate unlocked before starter picks were durably saved (saved_count={saved_count})")
281
  discover_source = get_json(f"/users/{creds.user_id}/recommendations?limit=8", headers=auth_headers(creds)).get("items", [])[0]["discovery_source"]
 
 
 
 
 
 
 
 
 
 
282
  return {
283
- "summary": "starter onboarding reaches full Discover with one-tap saves",
284
  "user_id": creds.user_id,
285
  "saved_count": saved_count,
 
286
  "first_play_directions": next(iter(user.get("plays", {}).values()))["directions"],
287
  "first_discovery_source": discover_source,
288
  }
@@ -314,8 +430,14 @@ def scenario_couple_audit(browser: Browser) -> dict[str, object]:
314
  }
315
  ]
316
 
317
- left_context = browser.new_context()
318
- right_context = browser.new_context()
 
 
 
 
 
 
319
  left_page = left_context.new_page()
320
  right_page = right_context.new_page()
321
 
@@ -599,8 +721,9 @@ def scenario_couple_audit(browser: Browser) -> dict[str, object]:
599
 
600
 
601
  SCENARIOS: dict[str, Callable[[Browser], dict[str, object]]] = {
602
- "starter": lambda browser: scenario_starter_gate(browser.new_page()),
603
- "settings": lambda browser: scenario_settings_controls(browser.new_page()),
 
604
  "couple": scenario_couple_audit,
605
  }
606
 
@@ -623,7 +746,15 @@ def normalize_scenarios(names: list[str]) -> list[str]:
623
  def run_selected_scenarios(selected: list[str], *, headless: bool = True) -> list[StageResult]:
624
  results: list[StageResult] = []
625
  with sync_playwright() as playwright:
626
- browser = playwright.chromium.launch(channel="chrome", headless=headless, args=["--no-sandbox"])
 
 
 
 
 
 
 
 
627
  try:
628
  for name in normalize_scenarios(selected):
629
  results.append(_run_stage(name, lambda scenario_name=name: SCENARIOS[scenario_name](browser)))
 
2
 
3
  import json
4
  import os
5
+ import threading
6
  import time
7
  import urllib.parse
8
  import urllib.request
9
  from dataclasses import asdict, dataclass
10
  from typing import Callable
11
 
12
+ from playwright.sync_api import Browser, Page, expect, sync_playwright
13
 
14
 
15
  BASE_URL = os.environ.get("KINK_AUDIT_BASE_URL", "http://127.0.0.1:8012").rstrip("/")
16
+ DEFAULT_SCENARIOS = ["onboarding", "starter", "settings", "couple"]
17
+ _POLL_WAIT = threading.Event()
18
+
19
+
20
+ def _e2e_onboarding_timeout_ms() -> int:
21
+ """HF cold start + first ``POST /users`` can block on catalog init; allow long UI waits."""
22
+ return int(os.environ.get("KINK_E2E_ONBOARDING_TIMEOUT_MS", "120000") or "120000")
23
 
24
 
25
  @dataclass
 
36
 
37
 
38
  def _json_request(path: str, *, method: str = "GET", payload: dict | None = None, headers: dict[str, str] | None = None, timeout: int = 60) -> dict:
39
+ request_headers: dict[str, str] = {}
40
+ if payload is not None:
41
+ request_headers["Content-Type"] = "application/json; charset=utf-8"
42
  if headers:
43
  request_headers.update(headers)
44
  request = urllib.request.Request(
 
193
 
194
 
195
  def open_app(page: Page, *, path: str = "") -> None:
196
+ page.goto(f"{BASE_URL}{path}", wait_until="load", timeout=120_000)
197
+
198
+
199
+ def _run_in_fresh_context(browser: Browser, fn: Callable[[Page], dict[str, object]]) -> dict[str, object]:
200
+ """Each scenario gets an isolated storage / cache origin (Playwright default context is shared)."""
201
+ context = browser.new_context(
202
+ viewport={"width": 1280, "height": 720},
203
+ extra_http_headers={"Cache-Control": "no-cache"},
204
+ )
205
+ page = context.new_page()
206
+ try:
207
+ return fn(page)
208
+ finally:
209
+ context.close()
210
 
211
 
212
  def create_profile_in_browser(page: Page, *, path: str = "") -> Credentials:
213
  open_app(page, path=path)
214
+ t_onboard = _e2e_onboarding_timeout_ms()
215
+ create_btn = page.get_by_test_id("onboarding-create-profile")
216
+ expect(create_btn).to_be_visible(timeout=min(30000, t_onboard))
217
+ create_btn.click()
218
+ expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=t_onboard)
219
  creds = Credentials(
220
  user_id=page.get_by_test_id("onboarding-created-user-id").inner_text().strip(),
221
  private_token=page.get_by_test_id("onboarding-created-private-token").inner_text().strip(),
 
235
  page.get_by_test_id("onboarding-open-profile").click()
236
 
237
 
238
+ def finish_starter_gate(page: Page, *, total_actions: int = 12) -> None:
239
+ # Single long wait here (avoid duplicating a long ``starter-gate`` expect before this helper — that pairing
240
+ # caused Chromium headless ``locator.click`` to hang on the first ``Like`` in practice).
241
+ expect(page.get_by_test_id("starter-gate")).to_be_visible(timeout=120_000)
242
+ if page.get_by_text("Could not load recommendations").count():
243
+ snippet = page.locator("body").inner_text()[:800]
244
+ raise AssertionError(f"Discover recommendations error in browser. Body snippet:\n{snippet}")
245
+ starter = page.locator('[data-testid="starter-discover-view"]')
246
+ expect(starter).to_be_visible(timeout=120_000)
247
+ expect(starter.get_by_test_id("discover-saved-count")).to_have_text("0 saved", timeout=120_000)
248
+ expect(starter.locator('[data-testid="discover-direction-strip"]')).to_be_visible(timeout=120_000)
249
+ bar = page.locator('[data-testid="discover-action-bar"]')
250
+ expect(bar).to_be_visible(timeout=120_000)
251
+ expect(bar.locator("button")).to_have_count(5, timeout=30_000)
252
+ expect(bar.locator("button").nth(0)).to_have_attribute("data-testid", "discover-rate-love", timeout=10_000)
253
+ expect(bar.locator("button").nth(1)).to_have_attribute("data-testid", "discover-rate-like", timeout=10_000)
254
+ # Always tap Like on the current top card (re-mounts each advance). Mixing Skip was flaky under headless transitions.
255
+ _expect_saved = (
256
+ "1 saved",
257
+ "2 saved",
258
+ "3 saved",
259
+ "4 saved",
260
+ "5 saved",
261
+ "6 saved",
262
+ "7 saved",
263
+ "8 saved",
264
+ "9 saved",
265
+ "10 saved",
266
+ "11 saved",
267
+ "12 saved",
268
+ )
269
+ if total_actions != 12:
270
+ raise AssertionError("finish_starter_gate only supports total_actions=12 (starter gate contract)")
271
+ for saved_label in _expect_saved:
272
+ starter = page.locator('[data-testid="starter-discover-view"]')
273
+ like_btn = page.get_by_test_id("discover-rate-like")
274
+ expect(like_btn).to_be_attached(timeout=30_000)
275
+ like_btn.click(force=True, timeout=120_000)
276
+ expect(starter.get_by_test_id("discover-saved-count")).to_have_text(saved_label, timeout=60_000)
277
+ # Leaving the starter gate swaps ``starter-discover-view`` for main ``discover-view`` (nav mounts at 1024px+).
278
+ expect(page.get_by_test_id("discover-view")).to_be_visible(timeout=90_000)
279
+ expect(page.get_by_test_id("nav-tab-discover")).to_be_visible(timeout=30_000)
280
 
281
 
282
  def open_together(page: Page) -> None:
 
308
  while time.time() < deadline:
309
  if predicate():
310
  return
311
+ _POLL_WAIT.wait(timeout=step_s)
312
  raise AssertionError(f"Timed out waiting for {label}")
313
 
314
 
 
331
  return StageResult(name=name, ok=False, details={"error": str(exc)})
332
 
333
 
334
+ def _seed_browser_session(page: Page, creds: Credentials) -> None:
335
+ """Open the app with an existing profile (same as returning visitor with saved localStorage)."""
336
+ open_app(page)
337
+ page.evaluate(
338
+ """([uid, tok]) => {
339
+ localStorage.clear();
340
+ try { sessionStorage.clear(); } catch (e) {}
341
+ localStorage.setItem("kink_auth", JSON.stringify({ userId: uid, token: tok }));
342
+ }""",
343
+ [creds.user_id, creds.private_token],
344
+ )
345
+ page.reload(wait_until="load", timeout=120_000)
346
+
347
+
348
+ def scenario_onboarding_create_button(page: Page) -> dict[str, object]:
349
+ """Minimal UX: welcome → Create profile → credential setup (covers POST /users + DOM)."""
350
+ open_app(page)
351
+ page.get_by_test_id("onboarding-create-profile").click()
352
+ expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=_e2e_onboarding_timeout_ms())
353
+ expect(page.get_by_test_id("onboarding-created-user-id")).to_be_visible(timeout=30_000)
354
+ return {"summary": "Create profile reaches credential + role setup screen"}
355
+
356
+
357
  def scenario_starter_gate(page: Page) -> dict[str, object]:
358
+ # Server-side user avoids fragile cold-start timing on POST /users vs first deck paint; starter + relogin is still all-UI.
359
+ creds = create_user()
360
+ fresh = get_user(creds)
361
+ if len(fresh.get("plays") or {}):
362
+ raise AssertionError(f"Expected new user with no plays before browser session, got {len(fresh['plays'])}")
363
+ rec_items = get_json(f"/users/{creds.user_id}/recommendations?limit=8", headers=auth_headers(creds)).get("items", [])
364
+ if len(rec_items) < 1:
365
+ raise AssertionError("New user has no recommendations before browser session (seed/API contract)")
366
+ _seed_browser_session(page, creds)
367
+ play_posts: list[str] = []
368
+ page.on(
369
+ "request",
370
+ lambda req: play_posts.append(req.url) if req.method == "POST" and "/plays" in req.url else None,
371
+ )
372
+ try:
373
+ finish_starter_gate(page)
374
+ except Exception as exc:
375
+ u_dbg = get_user(creds)
376
+ n_dbg = len(u_dbg.get("plays") or {})
377
+ keys_dbg = list((u_dbg.get("plays") or {}).keys())
378
+ raise AssertionError(
379
+ f"{exc}; after failure server play_count={n_dbg} play_keys={keys_dbg!r} "
380
+ f"browser_post_plays_count={len(play_posts)}"
381
+ ) from exc
382
  user = get_user(creds)
383
  saved_count = len(user.get("plays", {}))
384
  if saved_count < 12:
385
  raise AssertionError(f"starter gate unlocked before starter picks were durably saved (saved_count={saved_count})")
386
  discover_source = get_json(f"/users/{creds.user_id}/recommendations?limit=8", headers=auth_headers(creds)).get("items", [])[0]["discovery_source"]
387
+ # Full auth UX: leave app and log back in with the same credentials (guards logout/login + localStorage).
388
+ expect(page.get_by_test_id("discover-view")).to_be_visible(timeout=15000)
389
+ page.get_by_test_id("app-logout").click()
390
+ expect(page.get_by_test_id("onboarding-welcome")).to_be_visible(timeout=15000)
391
+ login_existing_user(page, creds)
392
+ expect(page.get_by_test_id("discover-view")).to_be_visible(timeout=15000)
393
+ user_after = get_user(creds)
394
+ saved_after = len(user_after.get("plays", {}))
395
+ if saved_after < 12:
396
+ raise AssertionError(f"plays missing after re-login (saved_after={saved_after})")
397
  return {
398
+ "summary": "create → starter discover logout → login → discover with durable plays",
399
  "user_id": creds.user_id,
400
  "saved_count": saved_count,
401
+ "saved_after_relogin": saved_after,
402
  "first_play_directions": next(iter(user.get("plays", {}).values()))["directions"],
403
  "first_discovery_source": discover_source,
404
  }
 
430
  }
431
  ]
432
 
433
+ left_context = browser.new_context(
434
+ viewport={"width": 1280, "height": 720},
435
+ extra_http_headers={"Cache-Control": "no-cache"},
436
+ )
437
+ right_context = browser.new_context(
438
+ viewport={"width": 1280, "height": 720},
439
+ extra_http_headers={"Cache-Control": "no-cache"},
440
+ )
441
  left_page = left_context.new_page()
442
  right_page = right_context.new_page()
443
 
 
721
 
722
 
723
  SCENARIOS: dict[str, Callable[[Browser], dict[str, object]]] = {
724
+ "onboarding": lambda browser: _run_in_fresh_context(browser, scenario_onboarding_create_button),
725
+ "starter": lambda browser: _run_in_fresh_context(browser, scenario_starter_gate),
726
+ "settings": lambda browser: _run_in_fresh_context(browser, scenario_settings_controls),
727
  "couple": scenario_couple_audit,
728
  }
729
 
 
746
  def run_selected_scenarios(selected: list[str], *, headless: bool = True) -> list[StageResult]:
747
  results: list[StageResult] = []
748
  with sync_playwright() as playwright:
749
+ launch_kwargs: dict[str, object] = {
750
+ "headless": headless,
751
+ # Shared HTTP cache across fresh contexts caused empty starter decks while urllib saw recs (Chromium).
752
+ "args": ["--no-sandbox", "--disable-http-cache"],
753
+ }
754
+ channel = os.environ.get("KINK_PLAYWRIGHT_CHANNEL", "").strip()
755
+ if channel:
756
+ launch_kwargs["channel"] = channel
757
+ browser = playwright.chromium.launch(**launch_kwargs)
758
  try:
759
  for name in normalize_scenarios(selected):
760
  results.append(_run_stage(name, lambda scenario_name=name: SCENARIOS[scenario_name](browser)))
tests/test_auth_login.py CHANGED
@@ -64,6 +64,30 @@ def test_login_unknown_user_404_not_wrong_password(api_client) -> None:
64
  assert "ephemeral" in detail.lower()
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def test_login_strips_whitespace_on_body_fields(api_client) -> None:
68
  client = TestClient(api_client.app)
69
  u = client.post("/users", json={}).json()
 
64
  assert "ephemeral" in detail.lower()
65
 
66
 
67
+ def test_new_user_has_recommendations_on_hf_seed(api_client) -> None:
68
+ """Playwright e2e depends on a non-empty starter deck from ``/recommendations``."""
69
+ client = TestClient(api_client.app)
70
+ u = client.post("/users", json={}).json()
71
+ r = client.get(
72
+ f"/users/{u['id']}/recommendations?limit=8",
73
+ headers={"x-private-token": u["private_token"]},
74
+ )
75
+ assert r.status_code == 200
76
+ assert len(r.json().get("items", [])) >= 1
77
+
78
+
79
+ def test_create_profile_then_get_user_immediately(api_client) -> None:
80
+ """UI: POST /users then GET /users/:id with token — must not 404/401 (HF create-profile flow)."""
81
+ client = TestClient(api_client.app)
82
+ u = client.post("/users", json={}).json()
83
+ uid = u["id"]
84
+ tok = u["private_token"]
85
+ r = client.get(f"/users/{uid}", headers={"x-private-token": tok})
86
+ assert r.status_code == 200
87
+ body = r.json()
88
+ assert body["id"] == uid
89
+
90
+
91
  def test_login_strips_whitespace_on_body_fields(api_client) -> None:
92
  client = TestClient(api_client.app)
93
  u = client.post("/users", json={}).json()
tests/test_e2e_playwright_product_flow.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Browser e2e: local uvicorn + Playwright starter flow (create, starter gate, relogin).
2
+
3
+ Requires ``curl`` on PATH and Playwright browsers: ``python -m playwright install chromium``
4
+ (optional ``KINK_PLAYWRIGHT_CHANNEL=chrome`` for system Chrome.)
5
+
6
+ Run: ``pytest tests/test_e2e_playwright_product_flow.py``
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ from importlib.util import module_from_spec, spec_from_file_location
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ _REPO = Path(__file__).resolve().parent.parent
24
+ _SEED = _REPO / "deploy/hf/seed/hf_bundled_store.db"
25
+ _POLL = threading.Event()
26
+
27
+
28
+ def _wait_s(seconds: float) -> None:
29
+ _POLL.wait(timeout=seconds)
30
+
31
+
32
+ def _free_port() -> int:
33
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
34
+ sock.bind(("127.0.0.1", 0))
35
+ return int(sock.getsockname()[1])
36
+
37
+
38
+ def _load_playwright_flow():
39
+ path = _REPO / "scripts" / "playwright_product_flow.py"
40
+ spec = spec_from_file_location("playwright_product_flow", path)
41
+ if spec is None or spec.loader is None:
42
+ raise RuntimeError(f"Cannot load {path}")
43
+ mod = module_from_spec(spec)
44
+ sys.modules[spec.name] = mod
45
+ spec.loader.exec_module(mod)
46
+ return mod
47
+
48
+
49
+ def _terminate_process(proc: subprocess.Popen[str]) -> None:
50
+ proc.terminate()
51
+ deadline = time.monotonic() + 15.0
52
+ while proc.poll() is None and time.monotonic() < deadline:
53
+ _wait_s(0.1)
54
+ if proc.poll() is None:
55
+ proc.kill()
56
+ if proc.stderr:
57
+ err = proc.stderr.read()
58
+ code = proc.returncode
59
+ if code not in (0, -15, -9) and err:
60
+ print(err, file=sys.stderr)
61
+
62
+
63
+ def _start_uvicorn_with_seed(tmp_path: Path) -> tuple[str, subprocess.Popen[str]]:
64
+ assert _SEED.is_file(), f"Missing seed DB: {_SEED}"
65
+ db = tmp_path / "e2e_playwright_store.db"
66
+ shutil.copyfile(_SEED, db)
67
+ port = _free_port()
68
+ base = f"http://127.0.0.1:{port}"
69
+ env = os.environ.copy()
70
+ env["KINK_STORE_PATH"] = str(db)
71
+ env["KINK_SKIP_HEAVY_WARM"] = "1"
72
+ env["KINK_HF_REQUIRE_FULL_CATALOG"] = "0"
73
+ env["KINK_FRONTEND_NO_CACHE"] = "1"
74
+ proc = subprocess.Popen(
75
+ [sys.executable, "-m", "uvicorn", "api:app", "--host", "127.0.0.1", f"--port={port}"],
76
+ cwd=str(_REPO),
77
+ env=env,
78
+ stdout=subprocess.DEVNULL,
79
+ stderr=subprocess.PIPE,
80
+ text=True,
81
+ )
82
+ url = f"{base}/health"
83
+ deadline = time.monotonic() + 120.0
84
+ while time.monotonic() < deadline:
85
+ completed = subprocess.run(
86
+ ["curl", "-sS", "-f", "-m", "5", url],
87
+ capture_output=True,
88
+ text=True,
89
+ check=False,
90
+ )
91
+ if completed.returncode == 0 and completed.stdout.strip():
92
+ data = json.loads(completed.stdout)
93
+ if data.get("ok") is True:
94
+ return base, proc
95
+ _wait_s(0.4)
96
+ _terminate_process(proc)
97
+ raise AssertionError("/health did not become ok within 120s (uvicorn stderr may have clues)")
98
+
99
+
100
+ @pytest.fixture(scope="module")
101
+ def _playwright_chromium_launchable() -> None:
102
+ from playwright.sync_api import sync_playwright
103
+
104
+ with sync_playwright() as p:
105
+ browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-http-cache"])
106
+ browser.close()
107
+
108
+
109
+ @pytest.fixture
110
+ def e2e_app_base_url(tmp_path: Path) -> str:
111
+ base, proc = _start_uvicorn_with_seed(tmp_path)
112
+ yield base
113
+ _terminate_process(proc)
114
+
115
+
116
+ def test_playwright_starter_create_relogin_e2e(
117
+ e2e_app_base_url: str,
118
+ _playwright_chromium_launchable: None,
119
+ ) -> None:
120
+ flow = _load_playwright_flow()
121
+ flow.BASE_URL = e2e_app_base_url
122
+ results = flow.run_selected_scenarios(["onboarding", "starter"], headless=True)
123
+ payload = flow.results_to_json(results)
124
+ if not payload["ok"]:
125
+ raise AssertionError(str(payload.get("summary", payload)))
tests/test_starter_deck_saves_api.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sequential play saves on a fresh user (guards starter deck API + play keys)."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ _SEED = Path(__file__).resolve().parent.parent / "deploy/hf/seed/hf_bundled_store.db"
12
+
13
+
14
+ @pytest.fixture()
15
+ def api_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
16
+ assert _SEED.is_file(), f"Missing seed DB: {_SEED}"
17
+ dest = tmp_path / "starter_saves_store.db"
18
+ shutil.copyfile(_SEED, dest)
19
+ monkeypatch.setenv("KINK_STORE_PATH", str(dest))
20
+ monkeypatch.setenv("KINK_SKIP_HEAVY_WARM", "1")
21
+ monkeypatch.setenv("KINK_HF_REQUIRE_FULL_CATALOG", "0")
22
+ sys.modules.pop("api", None)
23
+ import api as api_mod
24
+
25
+ api_mod._backend_impl = None
26
+ api_mod._get_backend()
27
+ return api_mod
28
+
29
+
30
+ def test_two_distinct_likes_increment_play_count(api_client) -> None:
31
+ client = TestClient(api_client.app)
32
+ u = client.post("/users", json={}).json()
33
+ uid, tok = u["id"], u["private_token"]
34
+ h = {"x-private-token": tok}
35
+ rec = client.get(f"/users/{uid}/recommendations?limit=8", headers=h).json()
36
+ items = rec.get("items") or []
37
+ assert len(items) >= 2, items
38
+ a = items[0]["kink"]["id"]
39
+ b = items[1]["kink"]["id"]
40
+ assert a != b
41
+ body = {"kink_id": a, "interest_state": "like", "directions": ["together"]}
42
+ r1 = client.post(f"/users/{uid}/plays", json=body, headers=h)
43
+ assert r1.status_code == 200, r1.text
44
+ r2 = client.post(
45
+ f"/users/{uid}/plays",
46
+ json={"kink_id": b, "interest_state": "like", "directions": ["together"]},
47
+ headers=h,
48
+ )
49
+ assert r2.status_code == 200, r2.text
50
+ plays = r2.json().get("plays") or {}
51
+ assert len(plays) == 2, plays
52
+
53
+
54
+ def test_recommendations_after_first_save_exclude_that_kink(api_client) -> None:
55
+ client = TestClient(api_client.app)
56
+ u = client.post("/users", json={}).json()
57
+ uid, tok = u["id"], u["private_token"]
58
+ h = {"x-private-token": tok}
59
+ rec0 = client.get(f"/users/{uid}/recommendations?limit=8", headers=h).json()
60
+ top = (rec0.get("items") or [])[0]["kink"]["id"]
61
+ client.post(
62
+ f"/users/{uid}/plays",
63
+ json={"kink_id": top, "interest_state": "like", "directions": ["together"]},
64
+ headers=h,
65
+ )
66
+ rec1 = client.get(f"/users/{uid}/recommendations?limit=8", headers=h).json()
67
+ ids = [row["kink"]["id"] for row in (rec1.get("items") or [])]
68
+ assert top not in ids, (top, ids[:5])