• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 11980840475

22 Nov 2024 10:04PM UTC coverage: 78.492% (-1.7%) from 80.158%
11980840475

Pull #637

github

goto-bus-stop
ci: add node 22
Pull Request #637: Switch to a relational database

757 of 912 branches covered (83.0%)

Branch coverage included in aggregate %.

2001 of 2791 new or added lines in 52 files covered. (71.69%)

9 existing lines in 7 files now uncovered.

8666 of 11093 relevant lines covered (78.12%)

70.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.93
/src/plugins/waitlist.js
1
import fs from 'node:fs';
1✔
2
import {
1✔
3
  PermissionError,
1✔
4
  UserNotFoundError,
1✔
5
  EmptyPlaylistError,
1✔
6
  WaitlistLockedError,
1✔
7
  AlreadyInWaitlistError,
1✔
8
  UserNotInWaitlistError,
1✔
9
  UserIsPlayingError,
1✔
10
} from '../errors/index.js';
1✔
11
import routes from '../routes/waitlist.js';
1✔
12
import { Permissions } from './acl.js';
1✔
13

1✔
14
const schema = JSON.parse(
1✔
15
  fs.readFileSync(new URL('../schemas/waitlist.json', import.meta.url), 'utf8'),
1✔
16
);
1✔
17

1✔
18
/**
1✔
19
 * @typedef {import('../schema.js').UserID} UserID
1✔
20
 * @typedef {import('../schema.js').User} User
1✔
21
 * @typedef {{ cycle: boolean, locked: boolean }} WaitlistSettings
1✔
22
 */
1✔
23

1✔
24
const ADD_TO_WAITLIST_SCRIPT = {
1✔
25
  keys: ['waitlist', 'booth:currentDJ'],
1✔
26
  lua: `
1✔
27
    local k_waitlist = KEYS[1]
1✔
28
    local k_dj = KEYS[2]
1✔
29
    local user_id = ARGV[1]
1✔
30
    local position = ARGV[2]
1✔
31
    local is_in_waitlist = redis.call('LPOS', k_waitlist, user_id)
1✔
32
    local current_dj = redis.call('GET', k_dj)
1✔
33
    if is_in_waitlist or current_dj == user_id then
1✔
34
      return { err = '${AlreadyInWaitlistError.code}' }
1✔
35
    end
1✔
36

1✔
37
    local before_id = nil
1✔
38
    if position then
1✔
39
      before_id = redis.call('LINDEX', k_waitlist, position)
1✔
40
    end
1✔
41

1✔
42
    if before_id then
1✔
43
      redis.call('LINSERT', k_waitlist, 'BEFORE', before_id)
1✔
44
    else
1✔
45
      redis.call('RPUSH', k_waitlist, user_id)
1✔
46
    end
1✔
47

1✔
48
    return redis.call('LRANGE', k_waitlist, 0, -1)
1✔
49
  `,
1✔
50
};
1✔
51

1✔
52
const MOVE_WAITLIST_SCRIPT = {
1✔
53
  keys: ['waitlist', 'booth:currentDJ'],
1✔
54
  lua: `
1✔
55
    local k_waitlist = KEYS[1]
1✔
56
    local k_dj = KEYS[2]
1✔
57
    local user_id = ARGV[1]
1✔
58
    local position = ARGV[2]
1✔
59
    local is_in_waitlist = redis.call('LPOS', k_waitlist, user_id)
1✔
60
    if not is_in_waitlist then
1✔
61
      return { err = '${UserNotInWaitlistError.code}' }
1✔
62
    end
1✔
63
    local current_dj = redis.call('GET', k_dj)
1✔
64
    if current_dj == user_id then
1✔
65
      return { err = '${UserIsPlayingError.code}' }
1✔
66
    end
1✔
67

1✔
68
    local before_id = redis.call('LINDEX', k_waitlist, position)
1✔
69

1✔
70
    redis.call('LREM', k_waitlist, 0, user_id);
1✔
71
    if before_id then
1✔
72
      redis.call('LINSERT', k_waitlist, 'BEFORE', before_id, user_id);
1✔
73
    else
1✔
74
      redis.call('RPUSH', k_waitlist, user_id)
1✔
75
    end
1✔
76

1✔
77
    return redis.call('LRANGE', k_waitlist, 0, -1)
1✔
78
  `,
1✔
79
};
1✔
80

1✔
81
class Waitlist {
92✔
82
  #uw;
92✔
83

92✔
84
  /**
92✔
85
   * @param {import('../Uwave.js').Boot} uw
92✔
86
   */
92✔
87
  constructor(uw) {
92✔
88
    this.#uw = uw;
92✔
89

92✔
90
    uw.config.register(schema['uw:key'], schema);
92✔
91
    uw.redis.defineCommand('uw:addToWaitlist', {
92✔
92
      numberOfKeys: ADD_TO_WAITLIST_SCRIPT.keys.length,
92✔
93
      lua: ADD_TO_WAITLIST_SCRIPT.lua,
92✔
94
    });
92✔
95
    uw.redis.defineCommand('uw:moveWaitlist', {
92✔
96
      numberOfKeys: MOVE_WAITLIST_SCRIPT.keys.length,
92✔
97
      lua: MOVE_WAITLIST_SCRIPT.lua,
92✔
98
    });
92✔
99

92✔
100
    const unsubscribe = uw.config.subscribe(
92✔
101
      schema['uw:key'],
92✔
102
      /**
92✔
103
       * @param {WaitlistSettings} _settings
92✔
104
       * @param {UserID|null} userID
92✔
105
       * @param {Partial<WaitlistSettings>} patch
92✔
106
       */
92✔
107
      (_settings, userID, patch) => {
92✔
108
        // TODO This userID != null check is wrong. It should always pass as
×
109
        // long as all the cases where waitlist settings can be updated provide
×
110
        // the moderator's user ID. There's no type level guarantee of that happening
×
111
        // though and if it doesn't, clients will get out of sync because of this check.
×
112
        if ('locked' in patch && patch.locked != null && userID != null) {
×
113
          this.#uw.publish('waitlist:lock', {
×
114
            moderatorID: userID,
×
115
            locked: patch.locked,
×
116
          });
×
117
        }
×
118
      },
92✔
119
    );
92✔
120
    uw.onClose(unsubscribe);
92✔
121
  }
92✔
122

92✔
123
  async #isBoothEmpty() {
92✔
124
    return !(await this.#uw.redis.get('booth:historyID'));
9✔
125
  }
9✔
126

92✔
127
  /**
92✔
128
   * @param {User} user
92✔
129
   * @returns {Promise<boolean>}
92✔
130
   */
92✔
131
  async #hasPlayablePlaylist(user) {
92✔
132
    const { playlists } = this.#uw;
12✔
133
    if (!user.activePlaylistID) {
12✔
134
      return false;
1✔
135
    }
1✔
136

11✔
137
    const playlist = await playlists.getUserPlaylist(user, user.activePlaylistID);
11✔
138
    return playlist && playlist.size > 0;
12✔
139
  }
12✔
140

92✔
141
  /**
92✔
142
   * @returns {Promise<WaitlistSettings>}
92✔
143
   */
92✔
144
  async #getSettings() {
92✔
145
    const { config } = this.#uw;
21✔
146

21✔
147
    const settings = /** @type {WaitlistSettings} */ (await config.get(schema['uw:key']));
21✔
148
    return settings;
21✔
149
  }
21✔
150

92✔
151
  /**
92✔
152
   * @returns {Promise<boolean>}
92✔
153
   */
92✔
154
  async isLocked() {
92✔
155
    const settings = await this.#getSettings();
15✔
156
    return settings.locked;
15✔
157
  }
15✔
158

92✔
159
  /**
92✔
160
   * @returns {Promise<boolean>}
92✔
161
   */
92✔
162
  async isCycleEnabled() {
92✔
163
    const settings = await this.#getSettings();
6✔
164
    return settings.cycle;
6✔
165
  }
6✔
166

92✔
167
  /**
92✔
168
   * @returns {Promise<UserID[]>}
92✔
169
   */
92✔
170
  getUserIDs() {
92✔
171
    return /** @type {Promise<UserID[]>} */ (this.#uw.redis.lrange('waitlist', 0, -1));
17✔
172
  }
17✔
173

92✔
174
  /**
92✔
175
   * used both for joining the waitlist, and for
92✔
176
   * adding someone else to the waitlist.
92✔
177
   * TODO maybe split this up and let http-api handle the difference
92✔
178
   *
92✔
179
   * @param {UserID} userID
92✔
180
   * @param {{moderator?: User}} [options]
92✔
181
   */
92✔
182
  async addUser(userID, options = {}) {
92✔
183
    const { moderator } = options;
13✔
184
    const { acl, users } = this.#uw;
13✔
185

13✔
186
    const user = await users.getUser(userID);
13✔
187
    if (!user) throw new UserNotFoundError({ id: userID });
13!
188

13✔
189
    const isAddingOtherUser = moderator && user.id !== moderator.id;
13✔
190
    if (isAddingOtherUser) {
13✔
191
      if (!(await acl.isAllowed(moderator, Permissions.WaitlistAdd))) {
2✔
192
        throw new PermissionError({
1✔
193
          requiredRole: 'waitlist.add',
1✔
194
        });
1✔
195
      }
1✔
196
    }
2✔
197

12✔
198
    const canForceJoin = await acl.isAllowed(user, Permissions.WaitlistJoinLocked);
12✔
199
    if (!isAddingOtherUser && !canForceJoin && await this.isLocked()) {
13!
200
      throw new WaitlistLockedError();
×
201
    }
×
202

12✔
203
    if (!(await this.#hasPlayablePlaylist(user))) {
13✔
204
      throw new EmptyPlaylistError();
2✔
205
    }
2✔
206

10✔
207
    try {
10✔
208
      const waitlist = /** @type {UserID[]} */ (await this.#uw.redis['uw:addToWaitlist'](...ADD_TO_WAITLIST_SCRIPT.keys, user.id));
13✔
209

9✔
210
      if (isAddingOtherUser) {
13✔
211
        this.#uw.publish('waitlist:add', {
1✔
212
          userID: user.id,
1✔
213
          moderatorID: moderator.id,
1✔
214
          position: waitlist.indexOf(user.id),
1✔
215
          waitlist,
1✔
216
        });
1✔
217
      } else {
13✔
218
        this.#uw.publish('waitlist:join', {
8✔
219
          userID: user.id,
8✔
220
          waitlist,
8✔
221
        });
8✔
222
      }
8✔
223
    } catch (error) {
13✔
224
      if (error.message === AlreadyInWaitlistError.code) {
1✔
225
        throw new AlreadyInWaitlistError();
1✔
226
      }
1✔
227
      throw error;
×
228
    }
×
229

9✔
230
    if (await this.#isBoothEmpty()) {
13✔
231
      await this.#uw.booth.advance();
6✔
232
    }
6✔
233
  }
13✔
234

92✔
235
  /**
92✔
236
   * @param {UserID} userID
92✔
237
   * @param {number} position
92✔
238
   * @param {{moderator: User}} options
92✔
239
   * @returns {Promise<void>}
92✔
240
   */
92✔
241
  async moveUser(userID, position, { moderator }) {
92✔
242
    const { users } = this.#uw;
×
243

×
NEW
244
    const user = await users.getUser(userID);
×
245
    if (!user) {
×
246
      throw new UserNotFoundError({ id: userID });
×
247
    }
×
248

×
249
    if (!(await this.#hasPlayablePlaylist(user))) {
×
250
      throw new EmptyPlaylistError();
×
251
    }
×
252

×
253
    try {
×
NEW
254
      const waitlist = /** @type {UserID[]} */ (await this.#uw.redis['uw:moveWaitlist'](...MOVE_WAITLIST_SCRIPT.keys, user.id, position));
×
255

×
256
      this.#uw.publish('waitlist:move', {
×
257
        userID: user.id,
×
258
        moderatorID: moderator.id,
×
259
        position: waitlist.indexOf(user.id),
×
260
        waitlist,
×
261
      });
×
262
    } catch (error) {
×
263
      if (error.message === UserNotInWaitlistError.code) {
×
264
        throw new UserNotInWaitlistError({ id: user.id });
×
265
      }
×
266
      if (error.message === UserIsPlayingError.code) {
×
267
        throw new UserIsPlayingError({ id: user.id });
×
268
      }
×
269
      throw error;
×
270
    }
×
271
  }
×
272

92✔
273
  /**
92✔
274
   * @param {UserID} userID
92✔
275
   * @param {{moderator: User}} options
92✔
276
   * @returns {Promise<void>}
92✔
277
   */
92✔
278
  async removeUser(userID, { moderator }) {
92✔
279
    const { acl, users } = this.#uw;
×
280
    const user = await users.getUser(userID);
×
281
    if (!user) {
×
282
      throw new UserNotFoundError({ id: userID });
×
283
    }
×
284

×
285
    const isRemoving = moderator && user.id !== moderator.id;
×
NEW
286
    if (isRemoving && !(await acl.isAllowed(moderator, Permissions.WaitlistRemove))) {
×
287
      throw new PermissionError({
×
288
        requiredRole: 'waitlist.remove',
×
289
      });
×
290
    }
×
291

×
292
    const removedCount = await this.#uw.redis.lrem('waitlist', 0, user.id);
×
293
    if (removedCount === 0) {
×
294
      throw new UserNotInWaitlistError({ id: user.id });
×
295
    }
×
296

×
297
    const waitlist = await this.getUserIDs();
×
298
    if (isRemoving) {
×
299
      this.#uw.publish('waitlist:remove', {
×
300
        userID: user.id,
×
301
        moderatorID: moderator.id,
×
302
        waitlist,
×
303
      });
×
304
    } else {
×
305
      this.#uw.publish('waitlist:leave', {
×
306
        userID: user.id,
×
307
        waitlist,
×
308
      });
×
309
    }
×
310
  }
×
311

92✔
312
  /**
92✔
313
   * @param {{moderator: User}} options
92✔
314
   * @returns {Promise<void>}
92✔
315
   */
92✔
316
  async clear({ moderator }) {
92✔
317
    await this.#uw.redis.del('waitlist');
×
318

×
319
    const waitlist = await this.getUserIDs();
×
320
    if (waitlist.length !== 0) {
×
321
      throw new Error('Could not clear the waitlist. Please try again.');
×
322
    }
×
323

×
324
    this.#uw.publish('waitlist:clear', {
×
325
      moderatorID: moderator.id,
×
326
    });
×
327
  }
×
328

92✔
329
  /**
92✔
330
   * @param {boolean} lock
92✔
331
   * @param {User} moderator
92✔
332
   * @returns {Promise<void>}
92✔
333
   */
92✔
334
  async #setWaitlistLocked(lock, moderator) {
92✔
335
    const settings = await this.#getSettings();
×
336
    await this.#uw.config.set(schema['uw:key'], { ...settings, locked: lock }, { user: moderator });
×
337
  }
×
338

92✔
339
  /**
92✔
340
   * Lock the waitlist. Only users with the `waitlist.join.locked` permission
92✔
341
   * will be able to join.
92✔
342
   *
92✔
343
   * @param {{moderator: User}} options
92✔
344
   * @returns {Promise<void>}
92✔
345
   */
92✔
346
  lock({ moderator }) {
92✔
347
    return this.#setWaitlistLocked(true, moderator);
×
348
  }
×
349

92✔
350
  /**
92✔
351
   * Unlock the waitlist. All users with the `waitlist.join` permission
92✔
352
   * will be able to join.
92✔
353
   *
92✔
354
   * @param {{moderator: User}} options
92✔
355
   * @returns {Promise<void>}
92✔
356
   */
92✔
357
  unlock({ moderator }) {
92✔
358
    return this.#setWaitlistLocked(false, moderator);
×
359
  }
×
360
}
92✔
361

1✔
362
/**
1✔
363
 * @param {import('../Uwave.js').Boot} uw
1✔
364
 * @returns {Promise<void>}
1✔
365
 */
1✔
366
async function waitlistPlugin(uw) {
92✔
367
  uw.waitlist = new Waitlist(uw);
92✔
368
  uw.httpApi.use('/waitlist', routes());
92✔
369
}
92✔
370

1✔
371
export default waitlistPlugin;
1✔
372
export { Waitlist };
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc