@saooti/octopus-sdk 41.7.1 → 41.7.3

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/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # CHANGELOG
2
2
 
3
- ## 41.7.1 (En cours)
3
+ ## 41.7.3 (11/03/2026)
4
+
5
+ **Features**
6
+
7
+ - Intégration du composable de vérification des droits
8
+
9
+ **Fix**
10
+
11
+ - Correction affichage épisodes à valider pour `PODCAST_VALIDATION`.
12
+
13
+ ## 41.7.2 (10/03/2026)
14
+
15
+ **Fix**
16
+
17
+ - Correction tri épisodes dans `PodcastPresentationList`
18
+
19
+ ## 41.7.1 (10/03/2026)
4
20
 
5
21
  **Fix**
6
22
 
package/index.ts CHANGED
@@ -118,6 +118,7 @@ export const getClassicTagInput = () => import("./src/components/form/ClassicTag
118
118
  export const getClassicWysiwyg = () => import("./src/components/form/ClassicWysiwyg.vue");
119
119
 
120
120
  //Composable
121
+ import { useRights, EditRight } from "./src/components/composable/useRights.ts";
121
122
  import {useResizePhone} from "./src/components/composable/useResizePhone";
122
123
  import {useTagOf} from "./src/components/composable/useTagOf.ts";
123
124
  import {useSelenium} from "./src/components/composable/useSelenium.ts";
@@ -202,6 +203,8 @@ import { ROUTE_PARAMS } from "./src/components/composable/route/types";
202
203
  import { defineAsyncComponent } from "vue";
203
204
 
204
205
  export {
206
+ useRights,
207
+ EditRight,
205
208
  useResizePhone,
206
209
  useTagOf,
207
210
  useSelenium,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.7.1",
3
+ "version": "41.7.3",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -9,6 +9,7 @@ import dayjs from "dayjs";
9
9
 
10
10
  import { RouteProps } from "./types";
11
11
  import { EmissionGroup, groupsApi } from "../../../api/groupsApi";
12
+ import { useRights } from "../useRights";
12
13
 
13
14
  export const useAdvancedParamInit = (props: RouteProps, isEmission: boolean) => {
14
15
 
@@ -18,7 +19,7 @@ export const useAdvancedParamInit = (props: RouteProps, isEmission: boolean) =>
18
19
 
19
20
  const filterStore = useFilterStore();
20
21
  const authStore = useAuthStore();
21
-
22
+ const { canValidatePodcast } = useRights();
22
23
 
23
24
  const isInit = ref(false);
24
25
  const monetisable = ref("UNDEFINED");// UNDEFINED, YES, NO
@@ -125,8 +126,8 @@ export const useAdvancedParamInit = (props: RouteProps, isEmission: boolean) =>
125
126
  includeHidden.value = undefined !== organisation.value && organisationRight.value && "false"!==props.routeIncludeHidden;
126
127
  }
127
128
 
128
- function initValidity(){
129
- const cantDisplay = isPodcastmaker.value || isEmission || !includeHidden.value || !authStore.isRoleContribution || !organisationRight.value;
129
+ function initValidity() {
130
+ const cantDisplay = isPodcastmaker.value || isEmission || !includeHidden.value || !canValidatePodcast();
130
131
  if(cantDisplay){
131
132
  validity.value = "true";
132
133
  }else{
@@ -0,0 +1,196 @@
1
+ import { useAuthStore } from "../../stores/AuthStore";
2
+ import type { Emission } from "../../stores/class/general/emission";
3
+ import type { Podcast } from "../../stores/class/general/podcast";
4
+
5
+ type Role =
6
+ 'ADMIN'|'ORGANISATION'|
7
+ 'PRODUCTION'|'RESTRICTED_PRODUCTION'|'PODCAST_CRUD'|'PODCAST_VALIDATION'|
8
+ 'PLAYLISTS'|'RESTRICTED_ANIMATION';
9
+
10
+ export enum EditRight {
11
+ None, // User cannot edit
12
+ Restricted, // User cannot edit because element is used elsewhere
13
+ Full // User can edit
14
+ }
15
+ /**
16
+ * Composable to manage rights.
17
+ * Based on AuthStore, but converts roles to easily usable tests for various
18
+ * actions.
19
+ */
20
+ export const useRights = () => {
21
+ const authStore = useAuthStore();
22
+
23
+ function roleContainsAny(...roles: Role[]): boolean {
24
+ return (authStore.authRole as Role[]).findIndex((r: Role) => roles.includes(r)) > -1;
25
+ }
26
+
27
+ // Creation is limited by roles
28
+ function canCreateEmission(): boolean {
29
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION');
30
+ }
31
+
32
+ function canEditEmission(emission: Emission): boolean {
33
+ if (
34
+ // Can edit new emissions
35
+ (!emission.emissionId && canCreateEmission()) ||
36
+ // Can edit when with sufficient rights
37
+ roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION')
38
+ ) {
39
+ return true;
40
+ }
41
+
42
+ // Can only edit if has created the emission
43
+ return (roleContainsAny('RESTRICTED_PRODUCTION') && emission.createdByUserId === authStore.authProfile?.userId);
44
+ }
45
+
46
+ function canDeleteEmission(): boolean {
47
+ // In case of restricted production, it will only delete podcasts
48
+ // created by user, and delete the emission only if empty afterwards
49
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION');
50
+ }
51
+
52
+ function canEditCommentsConfigEmission(): boolean {
53
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
54
+ }
55
+
56
+ function canCreatePodcast(): boolean {
57
+ // All roles that can create podcasts
58
+ return roleContainsAny(
59
+ 'ADMIN',
60
+ 'ORGANISATION',
61
+ 'PRODUCTION',
62
+ 'PODCAST_CRUD',
63
+ 'RESTRICTED_PRODUCTION',
64
+ 'RESTRICTED_ANIMATION'
65
+ );
66
+ }
67
+
68
+ function canDuplicatePodcast(): boolean {
69
+ // Same as creationm but notably without PODCAST_CRUD and
70
+ // RESTRICTED_ANIMATION
71
+ return roleContainsAny(
72
+ 'ADMIN',
73
+ 'ORGANISATION',
74
+ 'PRODUCTION',
75
+ 'RESTRICTED_PRODUCTION',
76
+ );
77
+ }
78
+
79
+ function canEditPodcast(podcast: Podcast): boolean {
80
+ // Full rights users can edit any podcast
81
+ if (roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION')) {
82
+ return true;
83
+ }
84
+
85
+ // RESTRICTED users can only edit their own podcasts
86
+ if (roleContainsAny('RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION')) {
87
+ return podcast.createdByUserId === authStore.authProfile?.userId;
88
+ }
89
+
90
+ // PODCAST_CRUD can only edit their own non-valid podcasts
91
+ if (roleContainsAny('PODCAST_CRUD')) {
92
+ return podcast.valid === false &&
93
+ podcast.publisher?.userId === authStore.authProfile?.userId;
94
+ }
95
+
96
+ return false;
97
+ }
98
+
99
+ function canDeletePodcast(podcast: Podcast): boolean {
100
+ // Same permissions as editing
101
+ return canEditPodcast(podcast);
102
+ }
103
+
104
+ function canValidatePodcast(): boolean {
105
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION', 'PODCAST_VALIDATION');
106
+ }
107
+
108
+ function canEditCommentsConfigPodcast(): boolean {
109
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
110
+ }
111
+
112
+ function canCreatePlaylist(): boolean {
113
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PLAYLISTS');
114
+ }
115
+
116
+ function canEditPlaylist(): boolean {
117
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PLAYLISTS');
118
+ }
119
+
120
+ function canDeletePlaylist(): boolean {
121
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PLAYLISTS');
122
+ }
123
+
124
+ function canCreateParticipant(): boolean {
125
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION');
126
+ }
127
+
128
+ async function getParticipantEditRight(participantId: number|undefined): Promise<EditRight> {
129
+ // New participants can be edited, and also with sufficient rights
130
+ if(!participantId || roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION')) {
131
+ return EditRight.Full;
132
+ } else {
133
+ return EditRight.None;
134
+ }
135
+ }
136
+
137
+ async function canEditParticipant(participantId: number|undefined): Promise<boolean> {
138
+ const editRight = await getParticipantEditRight(participantId);
139
+ return editRight === EditRight.Full;
140
+ }
141
+
142
+
143
+ async function canDeleteParticipant(participantId: number|undefined): Promise<boolean> {
144
+ const editRight = await getParticipantEditRight(participantId);
145
+ return editRight === EditRight.Full;
146
+ }
147
+
148
+ function canEditCodeInsertPlayer(): boolean {
149
+ return roleContainsAny('ADMIN', 'ORGANISATION');
150
+ }
151
+
152
+ function canEditTranscript(): boolean {
153
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
154
+ }
155
+
156
+ function canSeeHistory(): boolean {
157
+ return roleContainsAny('ADMIN', 'ORGANISATION');
158
+ }
159
+
160
+ function isRestrictedProduction(): boolean {
161
+ return roleContainsAny('RESTRICTED_PRODUCTION') && !roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
162
+ }
163
+
164
+ return {
165
+ // Emissions
166
+ canCreateEmission,
167
+ canEditEmission,
168
+ canDeleteEmission,
169
+ canEditCommentsConfigEmission,
170
+
171
+ // Podcasts
172
+ canCreatePodcast,
173
+ canDuplicatePodcast,
174
+ canEditPodcast,
175
+ canDeletePodcast,
176
+ canValidatePodcast,
177
+ canEditCommentsConfigPodcast,
178
+
179
+ // Playlists
180
+ canCreatePlaylist,
181
+ canEditPlaylist,
182
+ canDeletePlaylist,
183
+
184
+ // Participants
185
+ canCreateParticipant,
186
+ getParticipantEditRight,
187
+ canEditParticipant,
188
+ canDeleteParticipant,
189
+
190
+ // Other
191
+ canEditCodeInsertPlayer,
192
+ canEditTranscript,
193
+ canSeeHistory,
194
+ isRestrictedProduction
195
+ }
196
+ }
@@ -254,9 +254,7 @@ function createArrayDays() {
254
254
  });
255
255
  }
256
256
  }
257
- async function fetchOccurrencesAndLives(): Promise<
258
- Array<PlanningOccurrence | PlanningLive>
259
- > {
257
+ async function fetchOccurrencesAndLives(): Promise<Array<PlanningOccurrence|PlanningLive>> {
260
258
  const params = {
261
259
  canalId: props.radio?.id,
262
260
  from:startOfDay.value,
@@ -275,6 +273,7 @@ async function fetchOccurrencesAndLives(): Promise<
275
273
  path: "live/list",
276
274
  parameters: params,
277
275
  });
276
+
278
277
  if (lives.length) {
279
278
  occurrences = occurrences.concat(lives);
280
279
  occurrences.sort((a, b) => {
@@ -286,6 +285,7 @@ async function fetchOccurrencesAndLives(): Promise<
286
285
  }
287
286
  return occurrences;
288
287
  }
288
+
289
289
  async function fetchOccurrences(): Promise<void> {
290
290
  if (planning.value[daySelected.value]) {
291
291
  return;
@@ -310,6 +310,7 @@ async function fetchOccurrences(): Promise<void> {
310
310
  ) {
311
311
  periodDayIndex += 1;
312
312
  }
313
+
313
314
  switch (periodOfDay.value[periodDayIndex].id) {
314
315
  case "morning":
315
316
  planning.value[daySelected.value].morning.push(occ);
@@ -325,8 +326,9 @@ async function fetchOccurrences(): Promise<void> {
325
326
  }
326
327
  planningLength.value[daySelected.value] += 1;
327
328
  }
328
- } catch {
329
+ } catch(e) {
329
330
  error.value = true;
331
+ console.error(e);
330
332
  }
331
333
  loading.value = false;
332
334
  }
@@ -116,9 +116,15 @@ async function fetchNext(): Promise<void> {
116
116
  return simplifiedToFull(p, emission.orga, emission);
117
117
  })
118
118
  );
119
+
120
+ // Sort podcasts by pub date so that the most recent one is focused
121
+ podcasts.value.sort((p1, p2) => {
122
+ return new Date(p2.pubDate).getTime() - new Date(p1.pubDate).getTime();
123
+ });
124
+
119
125
  loading.value = false;
120
126
  } catch (errorWs) {
121
- console.log(errorWs);
127
+ console.error(errorWs);
122
128
  handle403(errorWs as AxiosError);
123
129
  error.value = true;
124
130
  }
@@ -0,0 +1,90 @@
1
+ import '@tests/mocks/useRouter';
2
+
3
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
4
+ import { defineComponent, nextTick } from 'vue';
5
+ import { mount as _mount } from '@vue/test-utils';
6
+
7
+ import { useAdvancedParamInit } from '@/components/composable/route/useAdvancedParamInit';
8
+ import { state } from '@/stores/ParamSdkStore';
9
+ import { setupPinia, setupAuthStore } from '@tests/utils';
10
+ import type { RouteProps } from '@/components/composable/route/types';
11
+
12
+ vi.mock('@/api/groupsApi', () => ({
13
+ groupsApi: { getAllById: vi.fn().mockResolvedValue({}) }
14
+ }));
15
+
16
+ async function setupComposable(
17
+ props: RouteProps,
18
+ isEmission: boolean,
19
+ patchStores?: () => void | Promise<void>
20
+ ): Promise<ReturnType<typeof useAdvancedParamInit>> {
21
+ let result!: ReturnType<typeof useAdvancedParamInit>;
22
+
23
+ const pinia = setupPinia();
24
+ await patchStores?.();
25
+
26
+ _mount(defineComponent({
27
+ setup() { result = useAdvancedParamInit(props, isEmission); return {}; },
28
+ template: '<div/>'
29
+ }), { global: { plugins: [pinia] } });
30
+
31
+ await nextTick(); // allow onMounted
32
+ await nextTick(); // allow isInit via nextTick in initAdvancedParams
33
+
34
+ return result;
35
+ }
36
+
37
+ describe('useAdvancedParamInit', () => {
38
+ beforeEach(() => {
39
+ state.generalParameters.podcastmaker = false;
40
+ });
41
+
42
+ describe('initValidity', () => {
43
+ const propsWithHidden: RouteProps = { routeValidity: 'false', routeIncludeHidden: 'true' };
44
+ const withOrg = (roles: string[]) => setupAuthStore({ roles, organisationId: 'test-org-id' });
45
+
46
+ describe('forces validity to "true"', () => {
47
+ it('when isEmission is true', async () => {
48
+ const { validity } = await setupComposable(propsWithHidden, true, withOrg(['PRODUCTION']));
49
+ expect(validity.value).toBe('true');
50
+ });
51
+
52
+ it('when isPodcastmaker is true', async () => {
53
+ state.generalParameters.podcastmaker = true;
54
+ const { validity } = await setupComposable(propsWithHidden, false, withOrg(['PRODUCTION']));
55
+ expect(validity.value).toBe('true');
56
+ });
57
+
58
+ it('when includeHidden is false (no org)', async () => {
59
+ // authOrgaId starts undefined → filterOrgaId undefined → organisation undefined → includeHidden false
60
+ const { validity } = await setupComposable(propsWithHidden, false);
61
+ expect(validity.value).toBe('true');
62
+ });
63
+
64
+ it('when routeIncludeHidden is "false"', async () => {
65
+ const { validity } = await setupComposable(
66
+ { routeValidity: 'false', routeIncludeHidden: 'false' },
67
+ false,
68
+ withOrg(['PRODUCTION'])
69
+ );
70
+ expect(validity.value).toBe('true');
71
+ });
72
+
73
+ (['PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'PLAYLISTS'] as const).forEach(role => {
74
+ it(`when role cannot validate (${role})`, async () => {
75
+ const { validity } = await setupComposable(propsWithHidden, false, withOrg([role]));
76
+ expect(validity.value).toBe('true');
77
+ });
78
+ });
79
+ });
80
+
81
+ describe('uses routeValidity when canValidatePodcast() is true', () => {
82
+ (['ADMIN', 'ORGANISATION', 'PRODUCTION', 'PODCAST_VALIDATION'] as const).forEach(role => {
83
+ it(`allows role ${role}`, async () => {
84
+ const { validity } = await setupComposable(propsWithHidden, false, withOrg([role]));
85
+ expect(validity.value).toBe('false');
86
+ });
87
+ });
88
+ });
89
+ });
90
+ });
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { setupPinia, setupAuthStore } from '@tests/utils';
3
+ import { useAuthStore } from '@/stores/AuthStore';
4
+ import { useRights } from '@/components/composable/useRights';
5
+ import type { Organisation } from '@/stores/class/general/organisation';
6
+ import type { Emission } from '@/stores/class/general/emission';
7
+ import type { Podcast } from '@/stores/class/general/podcast';
8
+
9
+ async function setup(roles: string[], userId = 'test-user-123'): Promise<void> {
10
+ setupPinia();
11
+ await setupAuthStore({ roles })();
12
+ useAuthStore().$patch({ authProfile: { userId } });
13
+ }
14
+
15
+ describe('useRights', () => {
16
+ describe('Emission permissions', () => {
17
+ describe('canCreateEmission', () => {
18
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION'].forEach(role => {
19
+ it(`allows ${role}`, async () => {
20
+ await setup([role]);
21
+ expect(useRights().canCreateEmission()).toBe(true);
22
+ });
23
+ });
24
+
25
+ it('denies unrelated roles', async () => {
26
+ await setup(['PLAYLISTS']);
27
+ expect(useRights().canCreateEmission()).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe('canEditEmission', () => {
32
+ it('allows ADMIN to edit any emission', async () => {
33
+ await setup(['ADMIN']);
34
+ expect(useRights().canEditEmission({ emissionId: 1, createdByUserId: 'other-user' } as Emission)).toBe(true);
35
+ });
36
+
37
+ it('allows RESTRICTED_PRODUCTION to edit own emission', async () => {
38
+ await setup(['RESTRICTED_PRODUCTION']);
39
+ expect(useRights().canEditEmission({ emissionId: 1, createdByUserId: 'test-user-123' } as Emission)).toBe(true);
40
+ });
41
+
42
+ it('denies RESTRICTED_PRODUCTION editing others\' emission', async () => {
43
+ await setup(['RESTRICTED_PRODUCTION']);
44
+ expect(useRights().canEditEmission({ emissionId: 1, createdByUserId: 'other-user' } as Emission)).toBe(false);
45
+ });
46
+
47
+ it('allows editing new emissions if can create', async () => {
48
+ await setup(['PRODUCTION']);
49
+ const newEmission = {
50
+ emissionId: undefined,
51
+ beneficiaries: [],
52
+ description: '',
53
+ monetisable: false,
54
+ name: 'New Emission',
55
+ orga: {} as Organisation,
56
+ rubriqueIds: []
57
+ } as unknown as Emission;
58
+ expect(useRights().canEditEmission(newEmission)).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe('canDeleteEmission', () => {
63
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION'].forEach(role => {
64
+ it(`allows ${role}`, async () => {
65
+ await setup([role]);
66
+ expect(useRights().canDeleteEmission()).toBe(true);
67
+ });
68
+ });
69
+
70
+ it('denies unrelated roles', async () => {
71
+ await setup(['PLAYLISTS']);
72
+ expect(useRights().canDeleteEmission()).toBe(false);
73
+ });
74
+ });
75
+ });
76
+
77
+ describe('Podcast permissions', () => {
78
+ describe('canCreatePodcast', () => {
79
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
80
+ it(`allows ${role}`, async () => {
81
+ await setup([role]);
82
+ expect(useRights().canCreatePodcast()).toBe(true);
83
+ });
84
+ });
85
+
86
+ it('denies unrelated roles', async () => {
87
+ await setup(['PLAYLISTS']);
88
+ expect(useRights().canCreatePodcast()).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('canDuplicatePodcast', () => {
93
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION'].forEach(role => {
94
+ it(`allows ${role}`, async () => {
95
+ await setup([role]);
96
+ expect(useRights().canDuplicatePodcast()).toBe(true);
97
+ });
98
+ });
99
+
100
+ it('denies unrelated roles', async () => {
101
+ await setup(['PLAYLISTS']);
102
+ expect(useRights().canDuplicatePodcast()).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('canEditPodcast', () => {
107
+ const ownPodcast = { podcastId: 1, createdByUserId: 'test-user-123', valid: true } as Podcast;
108
+ const otherPodcast = { podcastId: 1, createdByUserId: 'other-user', valid: true } as Podcast;
109
+
110
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION'].forEach(role => {
111
+ it(`allows ${role} to edit any podcast`, async () => {
112
+ await setup([role]);
113
+ expect(useRights().canEditPodcast(otherPodcast)).toBe(true);
114
+ });
115
+ });
116
+
117
+ it('allows PODCAST_CRUD to edit own non-valid podcast', async () => {
118
+ await setup(['PODCAST_CRUD']);
119
+ const podcast = { podcastId: 1, valid: false, publisher: { userId: 'test-user-123' } } as Podcast;
120
+ expect(useRights().canEditPodcast(podcast)).toBe(true);
121
+ });
122
+
123
+ it('denies PODCAST_CRUD editing own valid podcast', async () => {
124
+ await setup(['PODCAST_CRUD']);
125
+ const podcast = { podcastId: 1, valid: true, publisher: { userId: 'test-user-123' } } as Podcast;
126
+ expect(useRights().canEditPodcast(podcast)).toBe(false);
127
+ });
128
+
129
+ it('denies PODCAST_CRUD editing others\' podcast', async () => {
130
+ await setup(['PODCAST_CRUD']);
131
+ const podcast = { podcastId: 1, valid: false, publisher: { userId: 'other-user' } } as Podcast;
132
+ expect(useRights().canEditPodcast(podcast)).toBe(false);
133
+ });
134
+
135
+ ['RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
136
+ it(`allows ${role} to edit own podcast`, async () => {
137
+ await setup([role]);
138
+ expect(useRights().canEditPodcast(ownPodcast)).toBe(true);
139
+ });
140
+
141
+ it(`denies ${role} editing others' podcast`, async () => {
142
+ await setup([role]);
143
+ expect(useRights().canEditPodcast(otherPodcast)).toBe(false);
144
+ });
145
+ });
146
+
147
+ it('denies unrelated roles', async () => {
148
+ await setup(['PLAYLISTS']);
149
+ expect(useRights().canEditPodcast(ownPodcast)).toBe(false);
150
+ });
151
+
152
+ // Regression: RESTRICTED_* must take priority over PODCAST_CRUD when both roles are present.
153
+ ['RESTRICTED_PRODUCTION', 'RESTRICTED_ANIMATION'].forEach(role => {
154
+ it(`allows ${role} + PODCAST_CRUD to edit own valid podcast`, async () => {
155
+ await setup([role, 'PODCAST_CRUD']);
156
+ const podcast = { podcastId: 1, createdByUserId: 'test-user-123', valid: true, publisher: { userId: 'test-user-123' } } as Podcast;
157
+ expect(useRights().canEditPodcast(podcast)).toBe(true);
158
+ });
159
+ });
160
+
161
+ it('denies RESTRICTED_PRODUCTION + PODCAST_CRUD editing others\' valid podcast', async () => {
162
+ await setup(['RESTRICTED_PRODUCTION', 'PODCAST_CRUD']);
163
+ const podcast = { podcastId: 1, createdByUserId: 'other-user', valid: true, publisher: { userId: 'other-user' } } as Podcast;
164
+ expect(useRights().canEditPodcast(podcast)).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe('canDeletePodcast', () => {
169
+ it('delegates to canEditPodcast', async () => {
170
+ await setup(['RESTRICTED_PRODUCTION']);
171
+ expect(useRights().canDeletePodcast({ podcastId: 1, createdByUserId: 'test-user-123' } as Podcast)).toBe(true);
172
+ expect(useRights().canDeletePodcast({ podcastId: 1, createdByUserId: 'other-user' } as Podcast)).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('canValidatePodcast', () => {
177
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'PODCAST_VALIDATION'].forEach(role => {
178
+ it(`allows ${role}`, async () => {
179
+ await setup([role]);
180
+ expect(useRights().canValidatePodcast()).toBe(true);
181
+ });
182
+ });
183
+
184
+ ['PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'PLAYLISTS'].forEach(role => {
185
+ it(`denies ${role}`, async () => {
186
+ await setup([role]);
187
+ expect(useRights().canValidatePodcast()).toBe(false);
188
+ });
189
+ });
190
+ });
191
+ });
192
+
193
+ describe('Playlist permissions', () => {
194
+ ['canCreatePlaylist', 'canEditPlaylist', 'canDeletePlaylist'].forEach(method => {
195
+ describe(method, () => {
196
+ ['ADMIN', 'ORGANISATION', 'PLAYLISTS'].forEach(role => {
197
+ it(`allows ${role}`, async () => {
198
+ await setup([role]);
199
+ expect(useRights()[method as 'canCreatePlaylist']()).toBe(true);
200
+ });
201
+ });
202
+
203
+ it('denies unrelated roles', async () => {
204
+ await setup(['PRODUCTION']);
205
+ expect(useRights()[method as 'canCreatePlaylist']()).toBe(false);
206
+ });
207
+ });
208
+ });
209
+ });
210
+
211
+ describe('Participant permissions', () => {
212
+ describe('canCreateParticipant', () => {
213
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION'].forEach(role => {
214
+ it(`allows ${role}`, async () => {
215
+ await setup([role]);
216
+ expect(useRights().canCreateParticipant()).toBe(true);
217
+ });
218
+ });
219
+
220
+ it('denies unrelated roles', async () => {
221
+ await setup(['PLAYLISTS']);
222
+ expect(useRights().canCreateParticipant()).toBe(false);
223
+ });
224
+ });
225
+
226
+ ['canEditParticipant', 'canDeleteParticipant'].forEach(method => {
227
+ describe(method, () => {
228
+ ['ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION'].forEach(role => {
229
+ it(`allows ${role}`, async () => {
230
+ await setup([role]);
231
+ expect(await useRights()[method as 'canEditParticipant'](123)).toBe(true);
232
+ });
233
+ });
234
+
235
+ it('allows undefined id (new participant)', async () => {
236
+ await setup(['RESTRICTED_PRODUCTION']);
237
+ expect(await useRights()[method as 'canEditParticipant'](undefined)).toBe(true);
238
+ });
239
+
240
+ it('denies unrelated roles for existing participant', async () => {
241
+ await setup(['PLAYLISTS']);
242
+ expect(await useRights()[method as 'canEditParticipant'](123)).toBe(false);
243
+ });
244
+ });
245
+ });
246
+ });
247
+
248
+ describe('Other permissions', () => {
249
+ describe('canEditCodeInsertPlayer', () => {
250
+ ['ADMIN', 'ORGANISATION'].forEach(role => {
251
+ it(`allows ${role}`, async () => {
252
+ await setup([role]);
253
+ expect(useRights().canEditCodeInsertPlayer()).toBe(true);
254
+ });
255
+ });
256
+
257
+ ['PRODUCTION', 'PODCAST_CRUD', 'RESTRICTED_PRODUCTION', 'PLAYLISTS'].forEach(role => {
258
+ it(`denies ${role}`, async () => {
259
+ await setup([role]);
260
+ expect(useRights().canEditCodeInsertPlayer()).toBe(false);
261
+ });
262
+ });
263
+ });
264
+ });
265
+ });
package/tests/utils.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Component } from 'vue';
2
2
  import { vi } from 'vitest';
3
3
  import { mount as _mount, VueWrapper } from '@vue/test-utils';
4
- import { type Pinia, setActivePinia } from 'pinia';
4
+ import { type Pinia, setActivePinia, createPinia } from 'pinia';
5
5
  import { createTestingPinia } from '@pinia/testing';
6
6
 
7
7
  import { useAuthStore } from '../src/stores/AuthStore';
@@ -159,3 +159,14 @@ export function combineStoreSetups(...setups: Array<() => void>) {
159
159
  }
160
160
 
161
161
  export { VueWrapper };
162
+
163
+ /**
164
+ * Creates a Pinia instance without mounting a component.
165
+ * Use this for testing composables or APIs directly.
166
+ */
167
+ export function setupPinia(setupFn?: () => void | Promise<void>): Pinia {
168
+ const pinia = createPinia();
169
+ setActivePinia(pinia);
170
+ if (setupFn) { setupFn(); }
171
+ return pinia;
172
+ }