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

u-wave / core / 12018572686

25 Nov 2024 08:51PM UTC coverage: 82.056% (-0.03%) from 82.089%
12018572686

Pull #657

github

web-flow
Merge 3742dbfd0 into cbb61457b
Pull Request #657: SQLite Key-value store to replace Redis

840 of 1010 branches covered (83.17%)

Branch coverage included in aggregate %.

26 of 37 new or added lines in 2 files covered. (70.27%)

109 existing lines in 4 files now uncovered.

9115 of 11122 relevant lines covered (81.95%)

89.39 hits per line

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

81.08
/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 {
132✔
82
  #uw;
132✔
83

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

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

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

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

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

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

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

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

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

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

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

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

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

20✔
189
    const isAddingOtherUser = moderator && user.id !== moderator.id;
20✔
190
    if (isAddingOtherUser) {
20✔
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

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

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

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

14✔
210
      if (isAddingOtherUser) {
20✔
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 {
20✔
218
        this.#uw.publish('waitlist:join', {
13✔
219
          userID: user.id,
13✔
220
          waitlist,
13✔
221
        });
13✔
222
      }
13✔
223
    } catch (error) {
20✔
224
      if (error.message === AlreadyInWaitlistError.code) {
1✔
225
        throw new AlreadyInWaitlistError();
1✔
226
      }
1✔
227
      throw error;
×
228
    }
×
229

14✔
230
    if (await this.#isBoothEmpty()) {
20✔
231
      await this.#uw.booth.advance();
11✔
232
    }
11✔
233
  }
20✔
234

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

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

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

×
253
    try {
×
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

132✔
273
  /**
132✔
274
   * @param {UserID} userID
132✔
275
   * @param {{moderator: User}} options
132✔
276
   * @returns {Promise<void>}
132✔
277
   */
132✔
278
  async removeUser(userID, { moderator }) {
132✔
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;
×
286
    if (isRemoving && !(await acl.isAllowed(moderator, Permissions.WaitlistRemove))) {
×
287
      throw new PermissionError({
×
288
        requiredRole: 'waitlist.remove',
×
289
      });
×
290
    }
×
291

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

×
UNCOV
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
      });
×
UNCOV
309
    }
×
UNCOV
310
  }
×
311

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

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

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

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

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

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

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

© 2026 Coveralls, Inc