Spaces:
Sleeping
Sleeping
Sync from kink_cli (Docker Space)
Browse files- api.py +13 -1
- frontend/app.js +41 -15
- frontend/board-utils.js +10 -6
- frontend/discover-state.js +4 -2
- frontend/discover-state.test.mjs +9 -0
- frontend/discover.js +5 -4
- frontend/index.html +1 -1
- frontend/mutations.js +26 -17
- scripts/playwright_product_flow.py +153 -22
- tests/test_auth_login.py +24 -0
- tests/test_e2e_playwright_product_flow.py +125 -0
- tests/test_starter_deck_saves_api.py +68 -0
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 =
|
| 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=
|
| 19 |
import {
|
| 20 |
useLoginMutation,
|
| 21 |
useCreateMutation,
|
|
@@ -28,9 +28,9 @@ import {
|
|
| 28 |
useRemoveGroupMemberMutation,
|
| 29 |
useShareToggleMutation,
|
| 30 |
usePartnerRequestMutations,
|
| 31 |
-
} from "./mutations.js?v=
|
| 32 |
-
import { optimisticBoard, removeKinkFromItemsPayload, playCommitKey } from "./board-utils.js?v=
|
| 33 |
-
import { DiscoverView } from "./discover.js?v=
|
| 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,
|
| 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
|
| 355 |
-
const
|
|
|
|
| 356 |
const normalized = [...nextDirections].sort();
|
| 357 |
const existing = [...(prevState?.directions || [])].sort();
|
| 358 |
-
if (
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 !==
|
| 14 |
]),
|
| 15 |
);
|
| 16 |
|
| 17 |
const sourceItem =
|
| 18 |
-
findBoardItemInData(previousBoard,
|
| 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:
|
| 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 ===
|
| 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
|
|
|
|
| 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 !==
|
| 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 (
|
| 8 |
-
|
|
|
|
|
|
|
| 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=
|
| 7 |
-
import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js?v=
|
| 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
|
|
|
|
| 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=
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
| 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,
|
| 76 |
}
|
| 77 |
-
qc.setQueryData(["
|
| 78 |
-
old
|
| 79 |
-
? {
|
| 80 |
-
...old,
|
| 81 |
-
plays: {
|
| 82 |
-
...(old.plays || {}),
|
| 83 |
-
[vars.kinkId]: { interest_state: vars.interestState, directions },
|
| 84 |
-
},
|
| 85 |
-
}
|
| 86 |
-
: old,
|
| 87 |
);
|
| 88 |
-
|
| 89 |
-
if (!selectedKinkId) setSelectedKinkId(vars.kinkId);
|
| 90 |
setStatus("Saving...");
|
| 91 |
return {
|
| 92 |
previousUser,
|
| 93 |
previousBoard,
|
| 94 |
previousRecs,
|
| 95 |
recsKey,
|
| 96 |
-
commitKey: playCommitKey(
|
| 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,
|
| 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
|
|
|
|
|
|
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
def create_profile_in_browser(page: Page, *, path: str = "") -> Credentials:
|
| 191 |
open_app(page, path=path)
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
| 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, *,
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 603 |
-
"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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])
|