create-feedbax 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +1261 -26
- package/dist/index.js +1402 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/LICENSE +201 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +25 -0
- package/template/package.json +70 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +204 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +219 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +18 -0
- package/template/src/comment-creation.test.tsx +270 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +18 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +246 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +309 -0
- package/template/src/components/header.tsx +95 -0
- package/template/src/components/loader.tsx +10 -0
- package/template/src/components/profile-menu.tsx +89 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +194 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +52 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +97 -0
- package/template/src/feedback-runtime.ts +38 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +111 -0
- package/template/src/feedbax/config/index.ts +271 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +942 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/index.css +2138 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +10 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +130 -0
- package/template/src/overlay-post-detail.tsx +92 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +384 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +515 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +55 -0
- package/template/src/portal-feedback-server-function.ts +93 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +214 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +64 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +141 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +124 -0
- package/template/src/public-feedback-page.test.tsx +290 -0
- package/template/src/public-feedback-page.ts +75 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail.tsx +682 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +214 -0
- package/template/src/public-post-route.tsx +171 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +218 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +378 -0
- package/template/src/public-shell.test.tsx +518 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +131 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +25 -0
- package/template/src/routes/__root.tsx +116 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +45 -0
- package/template/src/server.ts +111 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +211 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/README.md +0 -7
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createFeedbackModule,
|
|
5
|
+
type BrowserCapability,
|
|
6
|
+
type PostStatus,
|
|
7
|
+
} from "./index";
|
|
8
|
+
|
|
9
|
+
describe("Feedback module", () => {
|
|
10
|
+
it("creates a participant Comment and reply only on a Published Post", async () => {
|
|
11
|
+
const now = new Date("2026-08-25T10:00:00.000Z");
|
|
12
|
+
const feedback = createFeedbackModule({
|
|
13
|
+
initialItems: [
|
|
14
|
+
{
|
|
15
|
+
id: "published-post",
|
|
16
|
+
slug: "roadmap-search",
|
|
17
|
+
title: "Roadmap search",
|
|
18
|
+
description: "Search the roadmap.",
|
|
19
|
+
type: "Feature Request",
|
|
20
|
+
status: "Planned",
|
|
21
|
+
published: true,
|
|
22
|
+
createdAt: now,
|
|
23
|
+
updatedAt: now,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "draft-post",
|
|
27
|
+
slug: "private-draft",
|
|
28
|
+
title: "Private draft",
|
|
29
|
+
description: "Not published.",
|
|
30
|
+
type: "General Feedback",
|
|
31
|
+
status: "New",
|
|
32
|
+
published: false,
|
|
33
|
+
createdAt: now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const comment = await feedback.createComment({
|
|
40
|
+
slug: "roadmap-search",
|
|
41
|
+
body: "Please include keyboard shortcuts.",
|
|
42
|
+
displayName: " Ari ",
|
|
43
|
+
});
|
|
44
|
+
const reply = await feedback.replyToCommentThread({
|
|
45
|
+
slug: "roadmap-search",
|
|
46
|
+
discussionId: comment.discussionId,
|
|
47
|
+
body: "That would be useful.",
|
|
48
|
+
displayName: "Mina",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(comment).toMatchObject({
|
|
52
|
+
body: "Please include keyboard shortcuts.",
|
|
53
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
54
|
+
});
|
|
55
|
+
expect(reply).toMatchObject({ discussionId: comment.discussionId });
|
|
56
|
+
await expect(
|
|
57
|
+
feedback.listCommentThreads({ slug: "roadmap-search" }),
|
|
58
|
+
).resolves.toMatchObject({
|
|
59
|
+
items: [
|
|
60
|
+
{
|
|
61
|
+
id: comment.discussionId,
|
|
62
|
+
comments: [
|
|
63
|
+
{ id: comment.id, body: comment.body },
|
|
64
|
+
{ id: reply.id, body: reply.body },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
await expect(
|
|
70
|
+
feedback.createComment({
|
|
71
|
+
slug: "private-draft",
|
|
72
|
+
body: "This must not be stored.",
|
|
73
|
+
displayName: "Ari",
|
|
74
|
+
}),
|
|
75
|
+
).rejects.toThrow("Published Posts");
|
|
76
|
+
await expect(
|
|
77
|
+
feedback.createComment({
|
|
78
|
+
slug: "roadmap-search",
|
|
79
|
+
body: "Missing name.",
|
|
80
|
+
displayName: " ",
|
|
81
|
+
}),
|
|
82
|
+
).rejects.toThrow("display name is required");
|
|
83
|
+
await expect(
|
|
84
|
+
feedback.replyToCommentThread({
|
|
85
|
+
slug: "roadmap-search",
|
|
86
|
+
discussionId: "another-post-or-resolved-thread",
|
|
87
|
+
body: "Must not be accepted.",
|
|
88
|
+
displayName: "Ari",
|
|
89
|
+
}),
|
|
90
|
+
).rejects.toThrow("existing open Comment Thread on this Post");
|
|
91
|
+
});
|
|
92
|
+
it("lists only open page-level Comment Threads for a Published Post in stable pages", async () => {
|
|
93
|
+
const feedback = createFeedbackModule({
|
|
94
|
+
initialItems: [
|
|
95
|
+
{
|
|
96
|
+
id: "post-1",
|
|
97
|
+
slug: "roadmap-search",
|
|
98
|
+
title: "Roadmap search",
|
|
99
|
+
description: "Search the roadmap.",
|
|
100
|
+
type: "Feature Request",
|
|
101
|
+
status: "Planned",
|
|
102
|
+
published: true,
|
|
103
|
+
createdAt: new Date("2026-08-01T09:00:00.000Z"),
|
|
104
|
+
updatedAt: new Date("2026-08-01T09:00:00.000Z"),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
initialComments: [
|
|
108
|
+
{
|
|
109
|
+
id: "comment-1",
|
|
110
|
+
postId: "post-1",
|
|
111
|
+
discussionId: "discussion-1",
|
|
112
|
+
body: "Could this support shortcuts?",
|
|
113
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
114
|
+
createdAt: new Date("2026-08-01T10:00:00.000Z"),
|
|
115
|
+
resolved: false,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "reply-1",
|
|
119
|
+
postId: "post-1",
|
|
120
|
+
discussionId: "discussion-1",
|
|
121
|
+
body: "That is on our roadmap.",
|
|
122
|
+
author: { kind: "product-team", displayName: "Product Team" },
|
|
123
|
+
createdAt: new Date("2026-08-01T11:00:00.000Z"),
|
|
124
|
+
resolved: false,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "inline-comment",
|
|
128
|
+
postId: "post-1",
|
|
129
|
+
discussionId: "inline-discussion",
|
|
130
|
+
body: "private inline note",
|
|
131
|
+
author: { kind: "product-team", displayName: "Product Team" },
|
|
132
|
+
createdAt: new Date("2026-08-01T12:00:00.000Z"),
|
|
133
|
+
resolved: false,
|
|
134
|
+
scope: "block",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
feedback.listCommentThreads({ slug: "roadmap-search" }),
|
|
141
|
+
).resolves.toEqual({
|
|
142
|
+
items: [
|
|
143
|
+
{
|
|
144
|
+
id: "discussion-1",
|
|
145
|
+
comments: [
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
id: "comment-1",
|
|
148
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
149
|
+
}),
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
id: "reply-1",
|
|
152
|
+
author: { kind: "product-team", displayName: "Product Team" },
|
|
153
|
+
}),
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
it("adds and removes a Vote from the server-authoritative count on a Published Post", async () => {
|
|
160
|
+
const now = new Date("2026-08-21T10:00:00.000Z");
|
|
161
|
+
const feedback = createFeedbackModule({
|
|
162
|
+
initialItems: [
|
|
163
|
+
{
|
|
164
|
+
id: "published-post",
|
|
165
|
+
slug: "keyboard-navigation",
|
|
166
|
+
title: "Keyboard navigation",
|
|
167
|
+
description: "Navigate without a mouse.",
|
|
168
|
+
type: "Feature Request",
|
|
169
|
+
status: "Planned",
|
|
170
|
+
published: true,
|
|
171
|
+
voteCount: 2,
|
|
172
|
+
createdAt: now,
|
|
173
|
+
updatedAt: now,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
feedback.changeVote({ slug: "keyboard-navigation", intention: "add" }),
|
|
180
|
+
).resolves.toEqual({ slug: "keyboard-navigation", voteCount: 3 });
|
|
181
|
+
await expect(
|
|
182
|
+
feedback.changeVote({ slug: "keyboard-navigation", intention: "remove" }),
|
|
183
|
+
).resolves.toEqual({ slug: "keyboard-navigation", voteCount: 2 });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("serializes concurrent Vote mutations for one Post within the module instance", async () => {
|
|
187
|
+
const now = new Date("2026-08-21T10:00:00.000Z");
|
|
188
|
+
const feedback = createFeedbackModule({
|
|
189
|
+
initialItems: [
|
|
190
|
+
{
|
|
191
|
+
id: "post",
|
|
192
|
+
slug: "post",
|
|
193
|
+
title: "Post",
|
|
194
|
+
description: "Visible.",
|
|
195
|
+
type: "Feature Request",
|
|
196
|
+
status: "Planned",
|
|
197
|
+
published: true,
|
|
198
|
+
voteCount: 0,
|
|
199
|
+
createdAt: now,
|
|
200
|
+
updatedAt: now,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
Promise.all([
|
|
207
|
+
feedback.changeVote({ slug: "post", intention: "add" }),
|
|
208
|
+
feedback.changeVote({ slug: "post", intention: "add" }),
|
|
209
|
+
]),
|
|
210
|
+
).resolves.toEqual([
|
|
211
|
+
{ slug: "post", voteCount: 1 },
|
|
212
|
+
{ slug: "post", voteCount: 2 },
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rejects Votes for unpublished Posts and clamps removals at zero", async () => {
|
|
217
|
+
const now = new Date("2026-08-21T10:00:00.000Z");
|
|
218
|
+
const feedback = createFeedbackModule({
|
|
219
|
+
initialItems: [
|
|
220
|
+
{
|
|
221
|
+
id: "published-post",
|
|
222
|
+
slug: "published-post",
|
|
223
|
+
title: "Published Post",
|
|
224
|
+
description: "Visible.",
|
|
225
|
+
type: "Feature Request",
|
|
226
|
+
status: "Planned",
|
|
227
|
+
published: true,
|
|
228
|
+
voteCount: 0,
|
|
229
|
+
createdAt: now,
|
|
230
|
+
updatedAt: now,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "draft-post",
|
|
234
|
+
slug: "draft-post",
|
|
235
|
+
title: "Draft Post",
|
|
236
|
+
description: "Private.",
|
|
237
|
+
type: "Feature Request",
|
|
238
|
+
status: "New",
|
|
239
|
+
published: false,
|
|
240
|
+
voteCount: 4,
|
|
241
|
+
createdAt: now,
|
|
242
|
+
updatedAt: now,
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await expect(
|
|
248
|
+
feedback.changeVote({ slug: "published-post", intention: "remove" }),
|
|
249
|
+
).resolves.toEqual({ slug: "published-post", voteCount: 0 });
|
|
250
|
+
await expect(
|
|
251
|
+
feedback.changeVote({ slug: "draft-post", intention: "add" }),
|
|
252
|
+
).rejects.toThrow("Votes are available only for Published Posts.");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("orders and paginates Top, Trending, and New deterministically", async () => {
|
|
256
|
+
const feedback = createFeedbackModule({
|
|
257
|
+
initialItems: Array.from({ length: 27 }, (_, index) => ({
|
|
258
|
+
id: `post-${String(index).padStart(2, "0")}`,
|
|
259
|
+
slug: `post-${index}`,
|
|
260
|
+
title: `Post ${index}`,
|
|
261
|
+
description: "Visible.",
|
|
262
|
+
type: "Feature Request" as const,
|
|
263
|
+
status: "Planned" as const,
|
|
264
|
+
published: true,
|
|
265
|
+
voteCount: index < 2 ? 100 : index,
|
|
266
|
+
createdAt: new Date(Date.UTC(2026, 7, index + 1)),
|
|
267
|
+
updatedAt: new Date(Date.UTC(2026, 7, index + 1)),
|
|
268
|
+
})),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const top = await feedback.listPublicPosts({ sort: "top" });
|
|
272
|
+
const trending = await feedback.listPublicPosts({ sort: "trending" });
|
|
273
|
+
const newest = await feedback.listPublicPosts({ sort: "new" });
|
|
274
|
+
const topNext = await feedback.listPublicPosts({
|
|
275
|
+
sort: "top",
|
|
276
|
+
cursor: top.nextCursor,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(top.items.map(({ slug }) => slug)).toEqual(
|
|
280
|
+
trending.items.map(({ slug }) => slug),
|
|
281
|
+
);
|
|
282
|
+
expect(top.items.slice(0, 2).map(({ slug }) => slug)).toEqual([
|
|
283
|
+
"post-1",
|
|
284
|
+
"post-0",
|
|
285
|
+
]);
|
|
286
|
+
expect(newest.items.slice(0, 2).map(({ slug }) => slug)).toEqual([
|
|
287
|
+
"post-26",
|
|
288
|
+
"post-25",
|
|
289
|
+
]);
|
|
290
|
+
expect(
|
|
291
|
+
[...top.items, ...topNext.items].map(({ slug }) => slug),
|
|
292
|
+
).toHaveLength(27);
|
|
293
|
+
expect(
|
|
294
|
+
new Set([...top.items, ...topNext.items].map(({ slug }) => slug)).size,
|
|
295
|
+
).toBe(27);
|
|
296
|
+
await expect(
|
|
297
|
+
feedback.listPublicPosts({ sort: "new", cursor: top.nextCursor }),
|
|
298
|
+
).rejects.toThrow("cursor does not match this feed view");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("submits a Post with persisted public identity and private edit authority", async () => {
|
|
302
|
+
const feedback = createFeedbackModule();
|
|
303
|
+
|
|
304
|
+
const submitted = await feedback.submitPost({
|
|
305
|
+
title: "Keyboard navigation",
|
|
306
|
+
description: "Let Participants navigate without a mouse.",
|
|
307
|
+
type: "Feature Request",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(submitted).toMatchObject({
|
|
311
|
+
id: expect.any(String),
|
|
312
|
+
slug: "keyboard-navigation",
|
|
313
|
+
title: "Keyboard navigation",
|
|
314
|
+
description: "Let Participants navigate without a mouse.",
|
|
315
|
+
type: "Feature Request",
|
|
316
|
+
status: "New",
|
|
317
|
+
published: false,
|
|
318
|
+
browserCapability: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
|
|
319
|
+
});
|
|
320
|
+
expect(submitted).not.toHaveProperty("browserCapabilityHash");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("suffixes duplicate Post slugs and keeps each slug immutable after title edits", async () => {
|
|
324
|
+
const feedback = createFeedbackModule();
|
|
325
|
+
const first = await feedback.submitPost({
|
|
326
|
+
title: "Dark mode!",
|
|
327
|
+
description: "Respect the device theme.",
|
|
328
|
+
type: "Feature Request",
|
|
329
|
+
});
|
|
330
|
+
const second = await feedback.submitPost({
|
|
331
|
+
title: "Dark mode",
|
|
332
|
+
description: "Offer a theme preference.",
|
|
333
|
+
type: "Feature Request",
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const edited = await feedback.editDraftPost({
|
|
337
|
+
id: first.id,
|
|
338
|
+
browserCapability: first.browserCapability,
|
|
339
|
+
title: "System theme",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect([first.slug, second.slug]).toEqual(["dark-mode", "dark-mode-2"]);
|
|
343
|
+
expect(edited).toMatchObject({
|
|
344
|
+
id: first.id,
|
|
345
|
+
slug: "dark-mode",
|
|
346
|
+
title: "System theme",
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("allocates distinct slugs for concurrent Post submissions", async () => {
|
|
351
|
+
const feedback = createFeedbackModule();
|
|
352
|
+
|
|
353
|
+
const [first, second] = await Promise.all([
|
|
354
|
+
feedback.submitPost({
|
|
355
|
+
title: "Dark mode",
|
|
356
|
+
description: "Respect the device theme.",
|
|
357
|
+
type: "Feature Request",
|
|
358
|
+
}),
|
|
359
|
+
feedback.submitPost({
|
|
360
|
+
title: "Dark mode",
|
|
361
|
+
description: "Offer a theme preference.",
|
|
362
|
+
type: "Feature Request",
|
|
363
|
+
}),
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
expect([first.slug, second.slug]).toEqual(["dark-mode", "dark-mode-2"]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("returns allowlisted public and authorized Draft Post projections without capabilities", async () => {
|
|
370
|
+
const createdAt = new Date("2026-08-21T10:00:00.000Z");
|
|
371
|
+
const feedback = createFeedbackModule({
|
|
372
|
+
initialItems: [
|
|
373
|
+
{
|
|
374
|
+
id: "published-storage-id",
|
|
375
|
+
slug: "keyboard-navigation",
|
|
376
|
+
title: "Keyboard navigation",
|
|
377
|
+
description: "Navigate without a mouse.",
|
|
378
|
+
type: "Feature Request",
|
|
379
|
+
status: "Planned",
|
|
380
|
+
published: true,
|
|
381
|
+
createdAt,
|
|
382
|
+
updatedAt: createdAt,
|
|
383
|
+
submitter: { email: "private@example.com" },
|
|
384
|
+
browserCapabilityHash: "private-hash",
|
|
385
|
+
pageBody: "Private Product Team notes",
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
const draft = await feedback.submitPost({
|
|
390
|
+
title: "Dark mode",
|
|
391
|
+
description: "Respect the device theme.",
|
|
392
|
+
type: "Feature Request",
|
|
393
|
+
submitter: { name: "Ama", email: "ama@example.com" },
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const publicPost = await feedback.getPublicPost("keyboard-navigation");
|
|
397
|
+
const draftPost = await feedback.getDraftPost({
|
|
398
|
+
id: draft.id,
|
|
399
|
+
browserCapability: draft.browserCapability,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(publicPost).toEqual({
|
|
403
|
+
slug: "keyboard-navigation",
|
|
404
|
+
title: "Keyboard navigation",
|
|
405
|
+
description: "Navigate without a mouse.",
|
|
406
|
+
type: "Feature Request",
|
|
407
|
+
status: "Planned",
|
|
408
|
+
createdAt,
|
|
409
|
+
updatedAt: createdAt,
|
|
410
|
+
voteCount: 0,
|
|
411
|
+
});
|
|
412
|
+
expect(draftPost).toMatchObject({
|
|
413
|
+
id: draft.id,
|
|
414
|
+
slug: "dark-mode",
|
|
415
|
+
submitter: { name: "Ama", email: "ama@example.com" },
|
|
416
|
+
status: "New",
|
|
417
|
+
});
|
|
418
|
+
expect(draftPost).not.toHaveProperty("browserCapability");
|
|
419
|
+
expect(draftPost).not.toHaveProperty("browserCapabilityHash");
|
|
420
|
+
expect(draftPost).not.toHaveProperty("pageBody");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns the original Post for a repeated trusted submission", async () => {
|
|
424
|
+
const feedback = createFeedbackModule();
|
|
425
|
+
|
|
426
|
+
const first = await feedback.submitTrustedPost({
|
|
427
|
+
externalId: "product-a:feedback-123",
|
|
428
|
+
title: "Keyboard navigation",
|
|
429
|
+
description: "Let users navigate the product without a mouse.",
|
|
430
|
+
type: "Feature Request",
|
|
431
|
+
submitter: { name: "Ama", email: "ama@example.com" },
|
|
432
|
+
});
|
|
433
|
+
const retried = await feedback.submitTrustedPost({
|
|
434
|
+
externalId: "product-a:feedback-123",
|
|
435
|
+
title: "A changed retry must not overwrite the original",
|
|
436
|
+
description: "The first accepted request remains canonical.",
|
|
437
|
+
type: "Bug Report",
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
expect(retried).toEqual(first);
|
|
441
|
+
expect(retried).toMatchObject({
|
|
442
|
+
title: "Keyboard navigation",
|
|
443
|
+
description: "Let users navigate the product without a mouse.",
|
|
444
|
+
type: "Feature Request",
|
|
445
|
+
status: "New",
|
|
446
|
+
});
|
|
447
|
+
expect(retried).not.toHaveProperty("browserCapability");
|
|
448
|
+
expect(retried).not.toHaveProperty("published");
|
|
449
|
+
expect(retried).not.toHaveProperty("submitter");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("lets only the correct Browser Capability withdraw its eligible draft", async () => {
|
|
453
|
+
const feedback = createFeedbackModule();
|
|
454
|
+
const submittedItem = await feedback.submitPost({
|
|
455
|
+
title: "Withdraw this draft",
|
|
456
|
+
description: "I no longer want to submit this.",
|
|
457
|
+
type: "General Feedback",
|
|
458
|
+
});
|
|
459
|
+
const wrongCapability =
|
|
460
|
+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as BrowserCapability;
|
|
461
|
+
|
|
462
|
+
await expect(
|
|
463
|
+
feedback.editDraftPost({
|
|
464
|
+
id: submittedItem.id,
|
|
465
|
+
browserCapability: wrongCapability,
|
|
466
|
+
title: "Unauthorized edit",
|
|
467
|
+
}),
|
|
468
|
+
).rejects.toThrow("Browser Capability did not authorize this draft.");
|
|
469
|
+
await expect(
|
|
470
|
+
feedback.withdrawDraftPost({
|
|
471
|
+
id: submittedItem.id,
|
|
472
|
+
browserCapability: wrongCapability,
|
|
473
|
+
}),
|
|
474
|
+
).rejects.toThrow("Browser Capability did not authorize this draft.");
|
|
475
|
+
|
|
476
|
+
await expect(
|
|
477
|
+
feedback.withdrawDraftPost({
|
|
478
|
+
id: submittedItem.id,
|
|
479
|
+
browserCapability: submittedItem.browserCapability,
|
|
480
|
+
}),
|
|
481
|
+
).resolves.toBeUndefined();
|
|
482
|
+
await expect(
|
|
483
|
+
feedback.editDraftPost({
|
|
484
|
+
id: submittedItem.id,
|
|
485
|
+
browserCapability: submittedItem.browserCapability,
|
|
486
|
+
title: "Too late",
|
|
487
|
+
}),
|
|
488
|
+
).rejects.toThrow("Browser Capability did not authorize this draft.");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("returns a Browser Capability that edits the permitted fields of its draft", async () => {
|
|
492
|
+
const feedback = createFeedbackModule();
|
|
493
|
+
const submittedItem = await feedback.submitPost({
|
|
494
|
+
title: "Original title",
|
|
495
|
+
description: "Original description",
|
|
496
|
+
type: "General Feedback",
|
|
497
|
+
submitter: {
|
|
498
|
+
name: "Ama",
|
|
499
|
+
email: "ama@example.com",
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(submittedItem.browserCapability).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
504
|
+
|
|
505
|
+
const editedItem = await feedback.editDraftPost({
|
|
506
|
+
id: submittedItem.id,
|
|
507
|
+
browserCapability: submittedItem.browserCapability,
|
|
508
|
+
title: "Corrected title",
|
|
509
|
+
description: "Corrected description",
|
|
510
|
+
type: "Bug Report",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(editedItem).toMatchObject({
|
|
514
|
+
id: submittedItem.id,
|
|
515
|
+
title: "Corrected title",
|
|
516
|
+
description: "Corrected description",
|
|
517
|
+
type: "Bug Report",
|
|
518
|
+
submitter: { name: "Ama", email: "ama@example.com" },
|
|
519
|
+
status: "New",
|
|
520
|
+
published: false,
|
|
521
|
+
});
|
|
522
|
+
expect(editedItem).not.toHaveProperty("browserCapability");
|
|
523
|
+
expect(editedItem).not.toHaveProperty("browserCapabilityHash");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it.each([
|
|
527
|
+
{ status: "New" as const, published: true },
|
|
528
|
+
{ status: "Reviewing" as const, published: false },
|
|
529
|
+
])(
|
|
530
|
+
"does not edit a draft after its lifecycle eligibility ends: $status, published=$published",
|
|
531
|
+
async ({ status, published }) => {
|
|
532
|
+
const browserCapability =
|
|
533
|
+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as BrowserCapability;
|
|
534
|
+
const feedback = createFeedbackModule({
|
|
535
|
+
initialItems: [
|
|
536
|
+
{
|
|
537
|
+
id: "ineligible-item",
|
|
538
|
+
slug: "ineligible-item",
|
|
539
|
+
title: "Protected title",
|
|
540
|
+
description: "Protected description",
|
|
541
|
+
type: "Feature Request",
|
|
542
|
+
status: status satisfies PostStatus,
|
|
543
|
+
published,
|
|
544
|
+
createdAt: new Date("2026-08-20T09:00:00.000Z"),
|
|
545
|
+
updatedAt: new Date("2026-08-20T09:00:00.000Z"),
|
|
546
|
+
voteCount: 0,
|
|
547
|
+
browserCapabilityHash:
|
|
548
|
+
"ZtNPunH49FD35FWYhT5Tv8I7vRKQJ8uxMaL0_9eHjNA",
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await expect(
|
|
554
|
+
feedback.editDraftPost({
|
|
555
|
+
id: "ineligible-item",
|
|
556
|
+
browserCapability,
|
|
557
|
+
title: "Unauthorized edit",
|
|
558
|
+
}),
|
|
559
|
+
).rejects.toThrow("Browser Capability did not authorize this draft.");
|
|
560
|
+
await expect(
|
|
561
|
+
feedback.withdrawDraftPost({
|
|
562
|
+
id: "ineligible-item",
|
|
563
|
+
browserCapability,
|
|
564
|
+
}),
|
|
565
|
+
).rejects.toThrow("Browser Capability did not authorize this draft.");
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
it("returns the public roadmap grouped by status and ordered by most recent update", async () => {
|
|
570
|
+
const createdAt = new Date("2026-08-01T09:00:00.000Z");
|
|
571
|
+
const feedback = createFeedbackModule({
|
|
572
|
+
initialItems: [
|
|
573
|
+
{
|
|
574
|
+
id: "planned-older",
|
|
575
|
+
slug: "planned-older",
|
|
576
|
+
title: "Older planned item",
|
|
577
|
+
description: "Planned first, updated earlier.",
|
|
578
|
+
type: "Feature Request",
|
|
579
|
+
status: "Planned",
|
|
580
|
+
published: true,
|
|
581
|
+
createdAt,
|
|
582
|
+
updatedAt: new Date("2026-08-18T09:00:00.000Z"),
|
|
583
|
+
submitter: { email: "private@example.com" },
|
|
584
|
+
pageBody: "Private Product Team notes",
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
id: "planned-newer",
|
|
588
|
+
slug: "planned-newer",
|
|
589
|
+
title: "Newer planned item",
|
|
590
|
+
description: "Planned second, updated later.",
|
|
591
|
+
type: "Bug Report",
|
|
592
|
+
status: "Planned",
|
|
593
|
+
published: true,
|
|
594
|
+
createdAt,
|
|
595
|
+
updatedAt: new Date("2026-08-20T09:00:00.000Z"),
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
id: "in-progress",
|
|
599
|
+
slug: "in-progress",
|
|
600
|
+
title: "Active work",
|
|
601
|
+
description: "Currently being implemented.",
|
|
602
|
+
type: "General Feedback",
|
|
603
|
+
status: "In Progress",
|
|
604
|
+
published: true,
|
|
605
|
+
createdAt,
|
|
606
|
+
updatedAt: new Date("2026-08-19T09:00:00.000Z"),
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
id: "shipped",
|
|
610
|
+
slug: "shipped",
|
|
611
|
+
title: "Delivered work",
|
|
612
|
+
description: "Already available.",
|
|
613
|
+
type: "Feature Request",
|
|
614
|
+
status: "Shipped",
|
|
615
|
+
published: true,
|
|
616
|
+
createdAt,
|
|
617
|
+
updatedAt: new Date("2026-08-17T09:00:00.000Z"),
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: "unpublished",
|
|
621
|
+
slug: "unpublished",
|
|
622
|
+
title: "Private plan",
|
|
623
|
+
description: "Not approved for public display.",
|
|
624
|
+
type: "Feature Request",
|
|
625
|
+
status: "Planned",
|
|
626
|
+
published: false,
|
|
627
|
+
createdAt,
|
|
628
|
+
updatedAt: new Date("2026-08-21T09:00:00.000Z"),
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
id: "outside-roadmap",
|
|
632
|
+
slug: "outside-roadmap",
|
|
633
|
+
title: "Still under review",
|
|
634
|
+
description: "Not part of the roadmap contract.",
|
|
635
|
+
type: "Feature Request",
|
|
636
|
+
status: "Reviewing",
|
|
637
|
+
published: true,
|
|
638
|
+
createdAt,
|
|
639
|
+
updatedAt: new Date("2026-08-22T09:00:00.000Z"),
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
await expect(feedback.getPublicPostRoadmap()).resolves.toEqual({
|
|
645
|
+
Planned: {
|
|
646
|
+
items: [
|
|
647
|
+
{
|
|
648
|
+
slug: "planned-newer",
|
|
649
|
+
title: "Newer planned item",
|
|
650
|
+
description: "Planned second, updated later.",
|
|
651
|
+
type: "Bug Report",
|
|
652
|
+
status: "Planned",
|
|
653
|
+
createdAt,
|
|
654
|
+
updatedAt: new Date("2026-08-20T09:00:00.000Z"),
|
|
655
|
+
voteCount: 0,
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
slug: "planned-older",
|
|
659
|
+
title: "Older planned item",
|
|
660
|
+
description: "Planned first, updated earlier.",
|
|
661
|
+
type: "Feature Request",
|
|
662
|
+
status: "Planned",
|
|
663
|
+
createdAt,
|
|
664
|
+
updatedAt: new Date("2026-08-18T09:00:00.000Z"),
|
|
665
|
+
voteCount: 0,
|
|
666
|
+
},
|
|
667
|
+
],
|
|
668
|
+
totalCount: 2,
|
|
669
|
+
},
|
|
670
|
+
"In Progress": {
|
|
671
|
+
items: [
|
|
672
|
+
{
|
|
673
|
+
slug: "in-progress",
|
|
674
|
+
title: "Active work",
|
|
675
|
+
description: "Currently being implemented.",
|
|
676
|
+
type: "General Feedback",
|
|
677
|
+
status: "In Progress",
|
|
678
|
+
createdAt,
|
|
679
|
+
updatedAt: new Date("2026-08-19T09:00:00.000Z"),
|
|
680
|
+
voteCount: 0,
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
totalCount: 1,
|
|
684
|
+
},
|
|
685
|
+
Shipped: {
|
|
686
|
+
items: [
|
|
687
|
+
{
|
|
688
|
+
slug: "shipped",
|
|
689
|
+
title: "Delivered work",
|
|
690
|
+
description: "Already available.",
|
|
691
|
+
type: "Feature Request",
|
|
692
|
+
status: "Shipped",
|
|
693
|
+
createdAt,
|
|
694
|
+
updatedAt: new Date("2026-08-17T09:00:00.000Z"),
|
|
695
|
+
voteCount: 0,
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
totalCount: 1,
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("pages each public roadmap status independently in groups of 25", async () => {
|
|
704
|
+
const feedback = createFeedbackModule({
|
|
705
|
+
initialItems: [
|
|
706
|
+
...Array.from({ length: 27 }, (_, index) => ({
|
|
707
|
+
id: `planned-${index + 1}`,
|
|
708
|
+
slug: `planned-${index + 1}`,
|
|
709
|
+
title: `Planned ${index + 1}`,
|
|
710
|
+
description: `Description ${index + 1}`,
|
|
711
|
+
type: "Feature Request" as const,
|
|
712
|
+
status: "Planned" as const,
|
|
713
|
+
published: true,
|
|
714
|
+
createdAt: new Date(Date.UTC(2026, 6, 1)),
|
|
715
|
+
updatedAt: new Date(Date.UTC(2026, 6, index + 1)),
|
|
716
|
+
})),
|
|
717
|
+
{
|
|
718
|
+
id: "shipped-1",
|
|
719
|
+
slug: "shipped-1",
|
|
720
|
+
title: "Shipped 1",
|
|
721
|
+
description: "Already delivered.",
|
|
722
|
+
type: "Bug Report" as const,
|
|
723
|
+
status: "Shipped" as const,
|
|
724
|
+
published: true,
|
|
725
|
+
createdAt: new Date(Date.UTC(2026, 6, 1)),
|
|
726
|
+
updatedAt: new Date(Date.UTC(2026, 6, 28)),
|
|
727
|
+
},
|
|
728
|
+
],
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const firstPage = await feedback.listPublicRoadmapPosts({
|
|
732
|
+
status: "Planned",
|
|
733
|
+
});
|
|
734
|
+
const secondPage = await feedback.listPublicRoadmapPosts({
|
|
735
|
+
status: "Planned",
|
|
736
|
+
cursor: firstPage.nextCursor,
|
|
737
|
+
});
|
|
738
|
+
const roadmap = await feedback.getPublicPostRoadmap();
|
|
739
|
+
|
|
740
|
+
expect(firstPage.items).toHaveLength(25);
|
|
741
|
+
expect(firstPage.items[0]?.title).toBe("Planned 27");
|
|
742
|
+
expect(firstPage.items[24]?.title).toBe("Planned 3");
|
|
743
|
+
expect(firstPage.nextCursor).toEqual(expect.any(String));
|
|
744
|
+
expect(firstPage.totalCount).toBe(27);
|
|
745
|
+
expect(secondPage.items.map((item) => item.title)).toEqual([
|
|
746
|
+
"Planned 2",
|
|
747
|
+
"Planned 1",
|
|
748
|
+
]);
|
|
749
|
+
expect(secondPage.nextCursor).toBeUndefined();
|
|
750
|
+
expect(secondPage.totalCount).toBe(27);
|
|
751
|
+
expect(roadmap.Planned).toEqual(firstPage);
|
|
752
|
+
expect(roadmap.Shipped).toMatchObject({
|
|
753
|
+
totalCount: 1,
|
|
754
|
+
items: [{ title: "Shipped 1" }],
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("returns the newest 25 Published Posts in the first page", async () => {
|
|
759
|
+
const feedback = createFeedbackModule({
|
|
760
|
+
initialItems: Array.from({ length: 27 }, (_, index) => ({
|
|
761
|
+
id: `feedback-${index + 1}`,
|
|
762
|
+
slug: `feedback-${index + 1}`,
|
|
763
|
+
title: `Feedback ${index + 1}`,
|
|
764
|
+
description: `Description ${index + 1}`,
|
|
765
|
+
type: "Feature Request" as const,
|
|
766
|
+
status: "New" as const,
|
|
767
|
+
published: true,
|
|
768
|
+
createdAt: new Date(Date.UTC(2026, 7, index + 1)),
|
|
769
|
+
updatedAt: new Date(Date.UTC(2026, 7, index + 1)),
|
|
770
|
+
})),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const page = await feedback.listPublicPosts();
|
|
774
|
+
|
|
775
|
+
expect(page.items).toHaveLength(25);
|
|
776
|
+
expect(page.items[0]?.title).toBe("Feedback 27");
|
|
777
|
+
expect(page.items[24]?.title).toBe("Feedback 3");
|
|
778
|
+
expect(page.nextCursor).toEqual(expect.any(String));
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("advances filtered cursor pages without duplicates, gaps, or private fields", async () => {
|
|
782
|
+
const matchingItems = Array.from({ length: 30 }, (_, index) => ({
|
|
783
|
+
id: `matching-${index + 1}`,
|
|
784
|
+
slug: `matching-${index + 1}`,
|
|
785
|
+
title: `Matching ${index + 1}`,
|
|
786
|
+
description: `Description ${index + 1}`,
|
|
787
|
+
type: "Bug Report" as const,
|
|
788
|
+
status: "Planned" as const,
|
|
789
|
+
published: true,
|
|
790
|
+
createdAt: new Date(Date.UTC(2026, 6, index + 1)),
|
|
791
|
+
updatedAt: new Date(Date.UTC(2026, 6, index + 1)),
|
|
792
|
+
submitter: { email: `private-${index + 1}@example.com` },
|
|
793
|
+
browserCapabilityHash: `private-hash-${index + 1}`,
|
|
794
|
+
source: "Portal" as const,
|
|
795
|
+
pageBody: "Private notes",
|
|
796
|
+
customProperty: "private value",
|
|
797
|
+
}));
|
|
798
|
+
const feedback = createFeedbackModule({
|
|
799
|
+
initialItems: [
|
|
800
|
+
...matchingItems,
|
|
801
|
+
{
|
|
802
|
+
...matchingItems[0]!,
|
|
803
|
+
id: "wrong-type",
|
|
804
|
+
slug: "wrong-type",
|
|
805
|
+
title: "Wrong type",
|
|
806
|
+
type: "Feature Request",
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
...matchingItems[1]!,
|
|
810
|
+
id: "wrong-status",
|
|
811
|
+
slug: "wrong-status",
|
|
812
|
+
title: "Wrong status",
|
|
813
|
+
status: "Shipped",
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const firstPage = await feedback.listPublicPosts({
|
|
819
|
+
type: "Bug Report",
|
|
820
|
+
status: "Planned",
|
|
821
|
+
});
|
|
822
|
+
const secondPage = await feedback.listPublicPosts({
|
|
823
|
+
type: "Bug Report",
|
|
824
|
+
status: "Planned",
|
|
825
|
+
cursor: firstPage.nextCursor,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
expect(firstPage.items.map((item) => item.title)).toEqual(
|
|
829
|
+
Array.from({ length: 25 }, (_, index) => `Matching ${30 - index}`),
|
|
830
|
+
);
|
|
831
|
+
expect(secondPage.items.map((item) => item.title)).toEqual([
|
|
832
|
+
"Matching 5",
|
|
833
|
+
"Matching 4",
|
|
834
|
+
"Matching 3",
|
|
835
|
+
"Matching 2",
|
|
836
|
+
"Matching 1",
|
|
837
|
+
]);
|
|
838
|
+
expect(secondPage.nextCursor).toBeUndefined();
|
|
839
|
+
expect(Object.keys(secondPage.items[0]!).sort()).toEqual([
|
|
840
|
+
"createdAt",
|
|
841
|
+
"description",
|
|
842
|
+
"slug",
|
|
843
|
+
"status",
|
|
844
|
+
"title",
|
|
845
|
+
"type",
|
|
846
|
+
"updatedAt",
|
|
847
|
+
"voteCount",
|
|
848
|
+
]);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("composes Post search, multi-select filters, and sorting", async () => {
|
|
852
|
+
const base = {
|
|
853
|
+
status: "Planned" as const,
|
|
854
|
+
published: true,
|
|
855
|
+
createdAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
856
|
+
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
857
|
+
};
|
|
858
|
+
const feedback = createFeedbackModule({
|
|
859
|
+
initialItems: [
|
|
860
|
+
{
|
|
861
|
+
...base,
|
|
862
|
+
id: "matching-description",
|
|
863
|
+
slug: "matching-description",
|
|
864
|
+
title: "Keyboard navigation",
|
|
865
|
+
description: "Reveal search without using a pointer.",
|
|
866
|
+
type: "Feature Request",
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
...base,
|
|
870
|
+
id: "matching-title",
|
|
871
|
+
slug: "matching-title",
|
|
872
|
+
title: "Faster search",
|
|
873
|
+
description: "Find Posts by title.",
|
|
874
|
+
type: "Bug Report",
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
...base,
|
|
878
|
+
id: "wrong-status",
|
|
879
|
+
slug: "wrong-status",
|
|
880
|
+
title: "Search closed Posts",
|
|
881
|
+
description: "This should not match.",
|
|
882
|
+
type: "Bug Report",
|
|
883
|
+
status: "Closed",
|
|
884
|
+
},
|
|
885
|
+
],
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
const trending = await feedback.listPublicPosts({
|
|
889
|
+
search: "search",
|
|
890
|
+
types: ["Feature Request", "Bug Report"],
|
|
891
|
+
statuses: ["Planned"],
|
|
892
|
+
sort: "trending",
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
expect(trending.items.map((post) => post.slug).sort()).toEqual([
|
|
896
|
+
"matching-description",
|
|
897
|
+
"matching-title",
|
|
898
|
+
]);
|
|
899
|
+
});
|
|
900
|
+
});
|