@commonpub/layer 0.83.1 → 0.84.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/components/contest/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +61 -128
- package/components/contest/ContestStageCard.vue +200 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/composables/useContestEditor.ts +26 -1
- package/package.json +6 -6
- package/utils/contestStages.ts +80 -51
- package/utils/contestTemplates.ts +116 -0
- package/server/api/content/[id]/__tests__/versions.get.test.ts +0 -127
package/utils/contestStages.ts
CHANGED
|
@@ -109,23 +109,20 @@ export function blankTemplateField(): ContestSubmissionTemplateField {
|
|
|
109
109
|
return { key: '', label: '', type: 'text', required: false };
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
112
|
+
type FieldType = ContestSubmissionTemplateField['type'];
|
|
113
|
+
type TemplateField = ContestSubmissionTemplateField;
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
// ─── Array-level template ops (operate on ONE stage's submissionTemplate) ───
|
|
116
|
+
// The extracted ContestStageTemplateEditor works on a plain field array; the
|
|
117
|
+
// stage-indexed `withTemplate*` wrappers below delegate to these so both surfaces
|
|
118
|
+
// share one implementation (and the existing unit tests still exercise it).
|
|
119
|
+
|
|
120
|
+
export function templateFieldAdded(t: TemplateField[]): TemplateField[] {
|
|
121
|
+
return [...t, blankTemplateField()];
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
export function
|
|
122
|
-
|
|
123
|
-
i: number,
|
|
124
|
-
fi: number,
|
|
125
|
-
patch: Partial<ContestSubmissionTemplateField>,
|
|
126
|
-
): ContestStage[] {
|
|
127
|
-
const cur = (stages[i]?.submissionTemplate ?? []).map((f, idx) => (idx === fi ? { ...f, ...patch } : f));
|
|
128
|
-
return withTemplate(stages, i, cur);
|
|
124
|
+
export function templateFieldSet(t: TemplateField[], fi: number, patch: Partial<TemplateField>): TemplateField[] {
|
|
125
|
+
return t.map((f, idx) => (idx === fi ? { ...f, ...patch } : f));
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
/**
|
|
@@ -134,28 +131,17 @@ export function withTemplateFieldSet(
|
|
|
134
131
|
* organizer edited by hand is left alone — once entrants have submitted, keys
|
|
135
132
|
* are what artifact values hang off, so they must stay stable.
|
|
136
133
|
*/
|
|
137
|
-
export function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
fi: number,
|
|
141
|
-
label: string,
|
|
142
|
-
): ContestStage[] {
|
|
143
|
-
const field = stages[i]?.submissionTemplate?.[fi];
|
|
144
|
-
if (!field) return stages;
|
|
134
|
+
export function templateFieldLabelChanged(t: TemplateField[], fi: number, label: string): TemplateField[] {
|
|
135
|
+
const field = t[fi];
|
|
136
|
+
if (!field) return t;
|
|
145
137
|
const tracksLabel = !field.key || field.key === fieldKeyFromLabel(field.label);
|
|
146
|
-
|
|
147
|
-
? { label, key: fieldKeyFromLabel(label) }
|
|
148
|
-
: { label };
|
|
149
|
-
return withTemplateFieldSet(stages, i, fi, patch);
|
|
138
|
+
return templateFieldSet(t, fi, tracksLabel ? { label, key: fieldKeyFromLabel(label) } : { label });
|
|
150
139
|
}
|
|
151
140
|
|
|
152
|
-
export function
|
|
153
|
-
|
|
154
|
-
return withTemplate(stages, i, cur);
|
|
141
|
+
export function templateFieldRemoved(t: TemplateField[], fi: number): TemplateField[] {
|
|
142
|
+
return t.filter((_, idx) => idx !== fi);
|
|
155
143
|
}
|
|
156
144
|
|
|
157
|
-
type FieldType = ContestSubmissionTemplateField['type'];
|
|
158
|
-
|
|
159
145
|
/**
|
|
160
146
|
* Change a template field's type AND normalize the type-specific ancillary props
|
|
161
147
|
* so the stored field stays coherent (Phase 4): `address` forces `pii`; leaving
|
|
@@ -163,15 +149,10 @@ type FieldType = ContestSubmissionTemplateField['type'];
|
|
|
163
149
|
* `mustAccept`; entering `select` seeds one blank option; entering `agreement`
|
|
164
150
|
* defaults `mustAccept` true.
|
|
165
151
|
*/
|
|
166
|
-
export function
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
type: FieldType,
|
|
171
|
-
): ContestStage[] {
|
|
172
|
-
const field = stages[i]?.submissionTemplate?.[fi];
|
|
173
|
-
if (!field) return stages;
|
|
174
|
-
const patch: Partial<ContestSubmissionTemplateField> = { type };
|
|
152
|
+
export function templateFieldTypeChanged(t: TemplateField[], fi: number, type: FieldType): TemplateField[] {
|
|
153
|
+
const field = t[fi];
|
|
154
|
+
if (!field) return t;
|
|
155
|
+
const patch: Partial<TemplateField> = { type };
|
|
175
156
|
patch.options = type === 'select' ? (field.options?.length ? field.options : [{ value: '', label: '' }]) : undefined;
|
|
176
157
|
if (type === 'agreement') {
|
|
177
158
|
patch.mustAccept = field.mustAccept ?? true;
|
|
@@ -181,13 +162,64 @@ export function withTemplateFieldTypeChanged(
|
|
|
181
162
|
patch.mustAccept = undefined;
|
|
182
163
|
}
|
|
183
164
|
if (type === 'address') patch.pii = true;
|
|
184
|
-
return
|
|
165
|
+
return templateFieldSet(t, fi, patch);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function templateOptionAdded(t: TemplateField[], fi: number): TemplateField[] {
|
|
169
|
+
const field = t[fi];
|
|
170
|
+
if (!field) return t;
|
|
171
|
+
return templateFieldSet(t, fi, { options: [...(field.options ?? []), { value: '', label: '' }] });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function templateOptionSet(
|
|
175
|
+
t: TemplateField[],
|
|
176
|
+
fi: number,
|
|
177
|
+
oi: number,
|
|
178
|
+
patch: Partial<{ value: string; label: string }>,
|
|
179
|
+
): TemplateField[] {
|
|
180
|
+
const field = t[fi];
|
|
181
|
+
if (!field) return t;
|
|
182
|
+
const options = (field.options ?? []).map((o, idx) => (idx === oi ? { ...o, ...patch } : o));
|
|
183
|
+
return templateFieldSet(t, fi, { options });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function templateOptionRemoved(t: TemplateField[], fi: number, oi: number): TemplateField[] {
|
|
187
|
+
const field = t[fi];
|
|
188
|
+
if (!field) return t;
|
|
189
|
+
return templateFieldSet(t, fi, { options: (field.options ?? []).filter((_, idx) => idx !== oi) });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Stage-indexed wrappers (delegate to the array-level ops above) ───
|
|
193
|
+
|
|
194
|
+
function withTemplate(stages: ContestStage[], i: number, template: TemplateField[]): ContestStage[] {
|
|
195
|
+
return stages.map((s, idx) => (idx === i ? { ...s, submissionTemplate: template.length ? template : undefined } : s));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function withTemplateFieldAdded(stages: ContestStage[], i: number): ContestStage[] {
|
|
199
|
+
return withTemplate(stages, i, templateFieldAdded(stages[i]?.submissionTemplate ?? []));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function withTemplateFieldSet(stages: ContestStage[], i: number, fi: number, patch: Partial<TemplateField>): ContestStage[] {
|
|
203
|
+
return withTemplate(stages, i, templateFieldSet(stages[i]?.submissionTemplate ?? [], fi, patch));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function withTemplateFieldLabelChanged(stages: ContestStage[], i: number, fi: number, label: string): ContestStage[] {
|
|
207
|
+
if (!stages[i]?.submissionTemplate?.[fi]) return stages;
|
|
208
|
+
return withTemplate(stages, i, templateFieldLabelChanged(stages[i]!.submissionTemplate!, fi, label));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function withTemplateFieldRemoved(stages: ContestStage[], i: number, fi: number): ContestStage[] {
|
|
212
|
+
return withTemplate(stages, i, templateFieldRemoved(stages[i]?.submissionTemplate ?? [], fi));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function withTemplateFieldTypeChanged(stages: ContestStage[], i: number, fi: number, type: FieldType): ContestStage[] {
|
|
216
|
+
if (!stages[i]?.submissionTemplate?.[fi]) return stages;
|
|
217
|
+
return withTemplate(stages, i, templateFieldTypeChanged(stages[i]!.submissionTemplate!, fi, type));
|
|
185
218
|
}
|
|
186
219
|
|
|
187
220
|
export function withTemplateOptionAdded(stages: ContestStage[], i: number, fi: number): ContestStage[] {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return withTemplateFieldSet(stages, i, fi, { options: [...(field.options ?? []), { value: '', label: '' }] });
|
|
221
|
+
if (!stages[i]?.submissionTemplate?.[fi]) return stages;
|
|
222
|
+
return withTemplate(stages, i, templateOptionAdded(stages[i]!.submissionTemplate!, fi));
|
|
191
223
|
}
|
|
192
224
|
|
|
193
225
|
export function withTemplateOptionSet(
|
|
@@ -197,16 +229,13 @@ export function withTemplateOptionSet(
|
|
|
197
229
|
oi: number,
|
|
198
230
|
patch: Partial<{ value: string; label: string }>,
|
|
199
231
|
): ContestStage[] {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const options = (field.options ?? []).map((o, idx) => (idx === oi ? { ...o, ...patch } : o));
|
|
203
|
-
return withTemplateFieldSet(stages, i, fi, { options });
|
|
232
|
+
if (!stages[i]?.submissionTemplate?.[fi]) return stages;
|
|
233
|
+
return withTemplate(stages, i, templateOptionSet(stages[i]!.submissionTemplate!, fi, oi, patch));
|
|
204
234
|
}
|
|
205
235
|
|
|
206
236
|
export function withTemplateOptionRemoved(stages: ContestStage[], i: number, fi: number, oi: number): ContestStage[] {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return withTemplateFieldSet(stages, i, fi, { options: (field.options ?? []).filter((_, idx) => idx !== oi) });
|
|
237
|
+
if (!stages[i]?.submissionTemplate?.[fi]) return stages;
|
|
238
|
+
return withTemplate(stages, i, templateOptionRemoved(stages[i]!.submissionTemplate!, fi, oi));
|
|
210
239
|
}
|
|
211
240
|
|
|
212
241
|
/** Human label for each template field type (for the editor dropdown). */
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ContestStage, ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
2
|
+
import { markdownToBlockTuples } from '@commonpub/editor';
|
|
3
|
+
import { newStageId } from './contestStages';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Starter templates for a NEW contest. A blank create page is intimidating and
|
|
7
|
+
* leaves organisers to discover the (substantial) submission-form / stage / rubric
|
|
8
|
+
* machinery by hand, so create.vue seeds the `standard` template by default
|
|
9
|
+
* (ContestEditor, create mode, onMounted). Pure + flag-adaptive so it unit-tests in
|
|
10
|
+
* isolation and degrades gracefully on instances where the proposal/PII builder
|
|
11
|
+
* features are OFF.
|
|
12
|
+
*
|
|
13
|
+
* A BlockTuple is `[type, attrs]`; bodies are seeded as structured heading/paragraph
|
|
14
|
+
* blocks via `markdownToBlockTuples` (the same path contestBody.ts uses for legacy
|
|
15
|
+
* markdown), so the seeded copy renders identically in the editor canvas AND the
|
|
16
|
+
* public view — a raw `markdown` block would not (its attr key is `source`, not the
|
|
17
|
+
* `content` an ad-hoc tuple would carry).
|
|
18
|
+
*/
|
|
19
|
+
export type ContestTemplateBlock = [string, Record<string, unknown>];
|
|
20
|
+
|
|
21
|
+
export interface ContestTemplateSeed {
|
|
22
|
+
stages: ContestStage[];
|
|
23
|
+
currentStageId: string | null;
|
|
24
|
+
judgingCriteria: Array<{ label: string; weight?: number; description?: string }>;
|
|
25
|
+
descriptionBlocks: ContestTemplateBlock[];
|
|
26
|
+
rulesBlocks: ContestTemplateBlock[];
|
|
27
|
+
prizesBlocks: ContestTemplateBlock[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StandardTemplateOptions {
|
|
31
|
+
/** `features.contestProposals` — if on, the entry stage collects a proposal form
|
|
32
|
+
* (creates a draft project); else it falls back to attaching a published project. */
|
|
33
|
+
proposals: boolean;
|
|
34
|
+
/** `features.contestPii` — if on, seed a rules-acceptance `agreement` field (the
|
|
35
|
+
* agreement field type is only offered/edited in the builder when this is on). */
|
|
36
|
+
pii: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RULES_AGREEMENT_TERMS =
|
|
40
|
+
'By entering, I confirm this submission is my own original work and I agree to the contest rules and code of conduct.';
|
|
41
|
+
|
|
42
|
+
/** The proposal/entry stage's starter submission form (the approved general shape). */
|
|
43
|
+
function standardSubmissionTemplate(opts: StandardTemplateOptions): ContestSubmissionTemplateField[] {
|
|
44
|
+
const fields: ContestSubmissionTemplateField[] = [
|
|
45
|
+
{ key: 'project_name', label: 'Project name', type: 'text', required: true },
|
|
46
|
+
{ key: 'summary', label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
|
|
47
|
+
{ key: 'description', label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
|
|
48
|
+
{ key: 'approach', label: 'Approach', type: 'textarea', required: false, help: 'How you plan to build it (optional).' },
|
|
49
|
+
];
|
|
50
|
+
// The agreement field type is only surfaced in the builder when contestPii is on,
|
|
51
|
+
// so only seed it there — else it would be a hidden, un-editable required field.
|
|
52
|
+
if (opts.pii) {
|
|
53
|
+
fields.push({
|
|
54
|
+
key: 'rules_agreement',
|
|
55
|
+
label: 'Contest rules',
|
|
56
|
+
type: 'agreement',
|
|
57
|
+
required: true,
|
|
58
|
+
terms: RULES_AGREEMENT_TERMS,
|
|
59
|
+
mustAccept: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return fields;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The default rubric (contest-level; review stages fall back to it). */
|
|
66
|
+
function standardCriteria(): ContestTemplateSeed['judgingCriteria'] {
|
|
67
|
+
return [
|
|
68
|
+
{ label: 'Innovation', weight: 40, description: 'Originality and creativity of the idea.' },
|
|
69
|
+
{ label: 'Feasibility', weight: 30, description: 'How realistic and well-scoped the plan is.' },
|
|
70
|
+
{ label: 'Impact', weight: 30, description: 'Potential value to the community.' },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The standard new-contest template: a Proposals (submission) stage with a starter
|
|
76
|
+
* form + rules agreement, a Judging (review) stage, and a Results stage, plus a
|
|
77
|
+
* default rubric and starter Overview/Rules copy. Stage dates are intentionally
|
|
78
|
+
* unset — the organiser sets the schedule, then can set per-stage dates in the
|
|
79
|
+
* Stages tab.
|
|
80
|
+
*/
|
|
81
|
+
export function standardContestTemplate(opts: StandardTemplateOptions): ContestTemplateSeed {
|
|
82
|
+
const stages: ContestStage[] = [
|
|
83
|
+
{
|
|
84
|
+
id: newStageId(),
|
|
85
|
+
name: 'Proposals',
|
|
86
|
+
kind: 'submission',
|
|
87
|
+
description: 'Entrants submit a proposal for review.',
|
|
88
|
+
submissionMode: opts.proposals ? 'proposal' : 'attach',
|
|
89
|
+
submissionTemplate: standardSubmissionTemplate(opts),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: newStageId(),
|
|
93
|
+
name: 'Judging',
|
|
94
|
+
kind: 'review',
|
|
95
|
+
description: 'Judges score entries against the rubric.',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: newStageId(),
|
|
99
|
+
name: 'Results',
|
|
100
|
+
kind: 'results',
|
|
101
|
+
description: 'Final standings are published.',
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
return {
|
|
105
|
+
stages,
|
|
106
|
+
currentStageId: null,
|
|
107
|
+
judgingCriteria: standardCriteria(),
|
|
108
|
+
descriptionBlocks: markdownToBlockTuples(
|
|
109
|
+
'## About this contest\n\nDescribe who this contest is for, what to build, and why it matters. Replace this overview with your own.',
|
|
110
|
+
) as ContestTemplateBlock[],
|
|
111
|
+
rulesBlocks: markdownToBlockTuples(
|
|
112
|
+
'## Rules\n\n- Who can enter\n- What counts as a valid entry\n- How judging works\n\nReplace these with your contest rules.',
|
|
113
|
+
) as ContestTemplateBlock[],
|
|
114
|
+
prizesBlocks: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth-gating proving test for `GET /api/content/:id/versions`
|
|
3
|
-
* (audit session 204).
|
|
4
|
-
*
|
|
5
|
-
* Version history (titles, author, timestamps — incl. for unpublished drafts)
|
|
6
|
-
* was world-readable for any content id. The fix requires an authenticated
|
|
7
|
-
* caller AND that they be the content owner OR hold `content.moderate`.
|
|
8
|
-
*
|
|
9
|
-
* This drives the REAL route handler against a REAL (PGlite) DB seeded with a
|
|
10
|
-
* content item + a version row. The Nitro auth auto-imports are stubbed with
|
|
11
|
-
* their REAL semantics (requireAuth throws 401 when anonymous; ownerOrPermission
|
|
12
|
-
* returns true iff the caller is the owner or holds the permission), reading a
|
|
13
|
-
* per-test `currentUser` — so the handler's actual branch on the gate boolean
|
|
14
|
-
* and the DB row's authorId is exercised.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, it, expect, beforeAll } from 'vitest';
|
|
17
|
-
import type { H3Event } from 'h3';
|
|
18
|
-
import {
|
|
19
|
-
createTestDB,
|
|
20
|
-
createTestUser,
|
|
21
|
-
} from '../../../../../../../packages/server/src/__tests__/helpers/testdb';
|
|
22
|
-
import { contentItems, contentVersions } from '@commonpub/schema';
|
|
23
|
-
import type { DB } from '../../../../../../../packages/server/src/types';
|
|
24
|
-
|
|
25
|
-
interface HttpError extends Error {
|
|
26
|
-
statusCode: number;
|
|
27
|
-
}
|
|
28
|
-
interface TestUser {
|
|
29
|
-
id: string;
|
|
30
|
-
permissions: Set<string>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let db: DB;
|
|
34
|
-
let contentId: string;
|
|
35
|
-
let ownerId: string;
|
|
36
|
-
// null => anonymous. permissions => the set the caller holds.
|
|
37
|
-
let currentUser: TestUser | null;
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
const g = globalThis as Record<string, unknown>;
|
|
41
|
-
g.defineEventHandler = (fn: unknown): unknown => fn;
|
|
42
|
-
g.createError = (opts: { statusCode: number; statusMessage: string }): HttpError => {
|
|
43
|
-
const e = new Error(opts.statusMessage) as HttpError;
|
|
44
|
-
e.statusCode = opts.statusCode;
|
|
45
|
-
return e;
|
|
46
|
-
};
|
|
47
|
-
// Real semantics: 401 when there is no authenticated user.
|
|
48
|
-
g.requireAuth = (_event: H3Event): { id: string } => {
|
|
49
|
-
if (!currentUser) {
|
|
50
|
-
const e = new Error('Unauthorized') as HttpError;
|
|
51
|
-
e.statusCode = 401;
|
|
52
|
-
throw e;
|
|
53
|
-
}
|
|
54
|
-
return { id: currentUser.id };
|
|
55
|
-
};
|
|
56
|
-
g.useDB = (): DB => db;
|
|
57
|
-
// parseParams returns the id under test (route would parse from the path).
|
|
58
|
-
g.parseParams = (): { id: string } => ({ id: contentId });
|
|
59
|
-
// Real semantics: owner OR permission-holder.
|
|
60
|
-
g.ownerOrPermission = (_event: H3Event, resourceOwnerId: string, perm: string): boolean => {
|
|
61
|
-
if (!currentUser) return false;
|
|
62
|
-
if (currentUser.id === resourceOwnerId) return true;
|
|
63
|
-
return currentUser.permissions.has(perm);
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const handlerMod = await import('../versions.get');
|
|
68
|
-
const handler = handlerMod.default as (event: H3Event) => Promise<unknown>;
|
|
69
|
-
const fakeEvent = {} as H3Event;
|
|
70
|
-
|
|
71
|
-
function statusOf(p: Promise<unknown>): Promise<number | 'no-throw'> {
|
|
72
|
-
return p.then(
|
|
73
|
-
() => 'no-throw' as const,
|
|
74
|
-
(e: HttpError) => e.statusCode,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
beforeAll(async () => {
|
|
79
|
-
db = await createTestDB();
|
|
80
|
-
const owner = await createTestUser(db, { username: 'owner' });
|
|
81
|
-
ownerId = owner.id;
|
|
82
|
-
const [item] = await db
|
|
83
|
-
.insert(contentItems)
|
|
84
|
-
.values({
|
|
85
|
-
authorId: owner.id,
|
|
86
|
-
type: 'blog',
|
|
87
|
-
title: 'Draft',
|
|
88
|
-
slug: 'draft',
|
|
89
|
-
status: 'draft',
|
|
90
|
-
visibility: 'private',
|
|
91
|
-
content: [],
|
|
92
|
-
} as never)
|
|
93
|
-
.returning();
|
|
94
|
-
contentId = (item as { id: string }).id;
|
|
95
|
-
await db.insert(contentVersions).values({
|
|
96
|
-
contentId,
|
|
97
|
-
version: 1,
|
|
98
|
-
title: 'Draft v1',
|
|
99
|
-
createdById: owner.id,
|
|
100
|
-
} as never);
|
|
101
|
-
}, 30_000); // PGlite pushSchema is heavy; generous setup timeout under parallel load
|
|
102
|
-
|
|
103
|
-
describe('versions.get — author/moderator-only', () => {
|
|
104
|
-
it('anonymous caller → 401', async () => {
|
|
105
|
-
currentUser = null;
|
|
106
|
-
expect(await statusOf(handler(fakeEvent))).toBe(401);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('authenticated non-owner without content.moderate → 403', async () => {
|
|
110
|
-
currentUser = { id: 'some-other-user-id', permissions: new Set() };
|
|
111
|
-
expect(await statusOf(handler(fakeEvent))).toBe(403);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('owner → returns the version list', async () => {
|
|
115
|
-
currentUser = { id: ownerId, permissions: new Set() };
|
|
116
|
-
const result = (await handler(fakeEvent)) as Array<{ title: string | null }>;
|
|
117
|
-
expect(Array.isArray(result)).toBe(true);
|
|
118
|
-
expect(result.length).toBe(1);
|
|
119
|
-
expect(result[0]?.title).toBe('Draft v1');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('non-owner WITH content.moderate → returns the version list', async () => {
|
|
123
|
-
currentUser = { id: 'mod-user-id', permissions: new Set(['content.moderate']) };
|
|
124
|
-
const result = (await handler(fakeEvent)) as unknown[];
|
|
125
|
-
expect(result.length).toBe(1);
|
|
126
|
-
});
|
|
127
|
-
});
|