diff --git a/index.ts b/index.ts index de6b11f..c7c8b59 100644 --- a/index.ts +++ b/index.ts @@ -12,4 +12,5 @@ export { configure } from './configure.js' export { Session } from './src/session.js' export { stubsRoot } from './stubs/main.js' export { defineConfig, stores } from './src/define_config.js' +export { SessionCollection } from './src/session_collection.js' export { ReadOnlyValuesStore, ValuesStore } from './src/values_store.js' diff --git a/providers/session_provider.ts b/providers/session_provider.ts index 6a62360..67f1fc4 100644 --- a/providers/session_provider.ts +++ b/providers/session_provider.ts @@ -13,6 +13,7 @@ import type { ApplicationService } from '@adonisjs/core/types' import type { Session } from '../src/session.js' import SessionMiddleware from '../src/session_middleware.js' +import { SessionCollection } from '../src/session_collection.js' /** * Events emitted by the session class @@ -45,25 +46,35 @@ export default class SessionProvider { } /** - * Registering muddleware + * Resolves the session config from the config provider */ - register() { - this.app.container.singleton(SessionMiddleware, async (resolver) => { - const sessionConfigProvider = this.app.config.get('session', {}) + async #resolveConfig() { + const sessionConfigProvider = this.app.config.get('session', {}) + const config = await configProvider.resolve(this.app, sessionConfigProvider) + + if (!config) { + throw new RuntimeException( + 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' + ) + } - /** - * Resolve config from the provider - */ - const config = await configProvider.resolve(this.app, sessionConfigProvider) - if (!config) { - throw new RuntimeException( - 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' - ) - } + return config + } + /** + * Registering bindings + */ + register() { + this.app.container.singleton(SessionMiddleware, async (resolver) => { + const config = await this.#resolveConfig() const emitter = await resolver.make('emitter') return new SessionMiddleware(config, emitter) }) + + this.app.container.singleton(SessionCollection, async () => { + const config = await this.#resolveConfig() + return new SessionCollection(config) + }) } /** diff --git a/src/errors.ts b/src/errors.ts index 21ca81d..ebf8f2b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -26,3 +26,13 @@ export const E_SESSION_NOT_READY = createError( 'E_SESSION_NOT_READY', 500 ) + +/** + * Raised when trying to use tagging with a store that + * doesn't support tagging operations + */ +export const E_SESSION_TAGGING_NOT_SUPPORTED = createError( + 'Session store does not support tagging operations. Only Redis, Database and Memory stores support tagging.', + 'E_SESSION_TAGGING_NOT_SUPPORTED', + 500 +) diff --git a/src/session.ts b/src/session.ts index f0611e9..a8fea47 100644 --- a/src/session.ts +++ b/src/session.ts @@ -24,6 +24,7 @@ import type { SessionStoreFactory, AllowedSessionValues, SessionStoreContract, + SessionStoreWithTaggingContract, } from './types.js' /** @@ -34,7 +35,7 @@ import type { * uses a centralized persistence store and */ export class Session extends Macroable { - #store: SessionStoreContract + #store: SessionStoreContract | SessionStoreWithTaggingContract #emitter: EmitterService #ctx: HttpContext #readonly: boolean = false @@ -421,6 +422,18 @@ export class Session extends Macroable { this.#sessionId = cuid() } + /** + * Tag the current session with a user ID. This allows you to + * later retrieve all sessions for a given user via SessionCollection. + * + * Only Memory, Redis and Database stores support tagging. Other stores + * will throw an error. + */ + async tag(userId: string): Promise { + if (!('tag' in this.#store)) throw new errors.E_SESSION_TAGGING_NOT_SUPPORTED() + await this.#store.tag(this.#sessionId, userId) + } + /** * Commit session changes. No more mutations will be * allowed after commit. diff --git a/src/session_collection.ts b/src/session_collection.ts new file mode 100644 index 0000000..6cd8381 --- /dev/null +++ b/src/session_collection.ts @@ -0,0 +1,91 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import debug from './debug.js' +import { E_SESSION_TAGGING_NOT_SUPPORTED } from './errors.js' +import type { + ResolvedSessionConfig, + SessionData, + SessionStoreWithTaggingContract, + TaggedSession, +} from './types.js' + +/** + * SessionCollection provides APIs for programmatic session + * management. It allows reading, destroying, and tagging + * sessions without an HTTP context. + * + * @example + * ```ts + * import app from '@adonisjs/core/services/app' + * import { SessionCollection } from '@adonisjs/session' + * + * const sessionCollection = await app.container.make(SessionCollection) + * + * // List all sessions for a user + * const sessions = await sessionCollection.tagged(String(user.id)) + * + * // Destroy a specific session + * await sessionCollection.destroy(sessionId) + * ``` + */ +export class SessionCollection { + #store: SessionStoreWithTaggingContract + + constructor(config: ResolvedSessionConfig) { + const storeFactory = config.stores[config.store] + this.#store = storeFactory(null as any, config) as SessionStoreWithTaggingContract + } + + /** + * Check if the current store supports tagging + */ + supportsTagging(): boolean { + return 'tag' in this.#store && 'tagged' in this.#store + } + + /** + * Returns the session data for the given session ID, + * or null if the session does not exist + */ + async get(sessionId: string): Promise { + debug('session collection: getting session data %s', sessionId) + return this.#store.read(sessionId) + } + + /** + * Destroys a session by its ID + */ + async destroy(sessionId: string): Promise { + debug('session collection: destroying session %s', sessionId) + return this.#store.destroy(sessionId) + } + + /** + * Tag a session with a user ID. + * Only supported by Memory, Redis and Database stores. + */ + async tag(sessionId: string, userId: string): Promise { + debug('session collection: tagging session %s with user %s', sessionId, userId) + if (!this.supportsTagging()) throw new E_SESSION_TAGGING_NOT_SUPPORTED() + + return (this.#store as SessionStoreWithTaggingContract).tag(sessionId, userId) + } + + /** + * Get all sessions for a given user ID (tag). + * Only supported by Memory, Redis and Database stores. + */ + async tagged(userId: string): Promise { + debug('session collection: getting sessions tagged with user %s', userId) + if (!this.supportsTagging()) throw new E_SESSION_TAGGING_NOT_SUPPORTED() + + return (this.#store as SessionStoreWithTaggingContract).tagged(userId) + } +} diff --git a/src/stores/database.ts b/src/stores/database.ts index 9a77e0c..1b0682b 100644 --- a/src/stores/database.ts +++ b/src/stores/database.ts @@ -12,12 +12,12 @@ import { MessageBuilder } from '@adonisjs/core/helpers' import type { QueryClientContract } from '@adonisjs/lucid/types/database' import debug from '../debug.js' -import type { SessionStoreContract, SessionData } from '../types.js' +import type { SessionStoreWithTaggingContract, SessionData, TaggedSession } from '../types.js' /** * Database store to read/write session to SQL databases using Lucid */ -export class DatabaseStore implements SessionStoreContract { +export class DatabaseStore implements SessionStoreWithTaggingContract { #client: QueryClientContract #tableName: string #ttlSeconds: number @@ -67,6 +67,17 @@ export class DatabaseStore implements SessionStoreContract { } } + /** + * Parses and verifies session data using MessageBuilder + */ + #parseSessionData(contents: string, sessionId: string): SessionData | null { + try { + return new MessageBuilder().verify(contents, sessionId) + } catch { + return null + } + } + /** * Returns session data */ @@ -88,14 +99,7 @@ export class DatabaseStore implements SessionStoreContract { return null } - /** - * Verify contents with the session id and return them as an object - */ - try { - return new MessageBuilder().verify(row.data, sessionId) - } catch { - return null - } + return this.#parseSessionData(row.data, sessionId) } /** @@ -143,4 +147,38 @@ export class DatabaseStore implements SessionStoreContract { .where('id', sessionId) .update({ expires_at: expiresAt }) } + + /** + * Tag a session with a user ID + */ + async tag(sessionId: string, userId: string): Promise { + debug('database store: tagging session %s with user %s', sessionId, userId) + + await this.#client.from(this.#tableName).where('id', sessionId).update({ user_id: userId }) + } + + /** + * Converts a database row to a TaggedSession object + */ + #rowToTaggedSession(row: { id: string; data: string }): TaggedSession | null { + const data = this.#parseSessionData(row.data, row.id) + if (!data) return null + + return { id: row.id, data } + } + + /** + * Get all sessions for a given user ID (tag) + */ + async tagged(userId: string): Promise { + debug('database store: getting sessions tagged with user %s', userId) + + const rows = await this.#client + .from(this.#tableName) + .select('id', 'data') + .where('user_id', userId) + .where('expires_at', '>', new Date()) + + return rows.map((row) => this.#rowToTaggedSession(row)).filter((session) => session !== null) + } } diff --git a/src/stores/memory.ts b/src/stores/memory.ts index 3c4890a..e8f4a4f 100644 --- a/src/stores/memory.ts +++ b/src/stores/memory.ts @@ -7,14 +7,19 @@ * file that was distributed with this source code. */ -import type { SessionData, SessionStoreContract } from '../types.js' +import type { SessionData, SessionStoreWithTaggingContract, TaggedSession } from '../types.js' /** * Memory store is meant to be used for writing tests. */ -export class MemoryStore implements SessionStoreContract { +export class MemoryStore implements SessionStoreWithTaggingContract { static sessions: Map = new Map() + /** + * Maps session IDs to user IDs (for tagging) + */ + static tags: Map = new Map() + /** * Read session id value from the memory */ @@ -34,7 +39,29 @@ export class MemoryStore implements SessionStoreContract { */ destroy(sessionId: string): void { MemoryStore.sessions.delete(sessionId) + MemoryStore.tags.delete(sessionId) } touch(): void {} + + /** + * Tag a session with a user ID + */ + async tag(sessionId: string, userId: string): Promise { + MemoryStore.tags.set(sessionId, userId) + } + + /** + * Get all sessions for a given user ID (tag) + */ + async tagged(userId: string): Promise { + const sessions: TaggedSession[] = [] + + for (const [sessionId, taggedUserId] of MemoryStore.tags) { + const data = MemoryStore.sessions.get(sessionId) + if (taggedUserId === userId && data) sessions.push({ id: sessionId, data }) + } + + return sessions + } } diff --git a/src/stores/redis.ts b/src/stores/redis.ts index 3be9ba4..639d76c 100644 --- a/src/stores/redis.ts +++ b/src/stores/redis.ts @@ -12,12 +12,12 @@ import { MessageBuilder } from '@adonisjs/core/helpers' import type { Connection } from '@adonisjs/redis/types' import debug from '../debug.js' -import type { SessionStoreContract, SessionData } from '../types.js' +import type { SessionStoreWithTaggingContract, SessionData, TaggedSession } from '../types.js' /** - * File store to read/write session to filesystem + * Redis store to read/write session to Redis */ -export class RedisStore implements SessionStoreContract { +export class RedisStore implements SessionStoreWithTaggingContract { #connection: Connection #ttlSeconds: number @@ -28,8 +28,26 @@ export class RedisStore implements SessionStoreContract { } /** - * Returns file contents. A new file will be created if it's - * missing. + * Returns the key for a user's tag set (stores session IDs for a user) + */ + #getTagKey(userId: string): string { + return `session_tag:${userId}` + } + + /** + * Verify contents with the session id and return them as an object. The verify + * method can fail when the contents is not JSON + */ + #parseSessionData(contents: string, sessionId: string): SessionData | null { + try { + return new MessageBuilder().verify(contents, sessionId) + } catch { + return null + } + } + + /** + * Returns session data */ async read(sessionId: string): Promise { debug('redis store: reading session data %s', sessionId) @@ -39,21 +57,13 @@ export class RedisStore implements SessionStoreContract { return null } - /** - * Verify contents with the session id and return them as an object. The verify - * method can fail when the contents is not JSON> - */ - try { - return new MessageBuilder().verify(contents, sessionId) - } catch { - return null - } + return this.#parseSessionData(contents, sessionId) } /** - * Write session values to a file + * Write session values to redis */ - async write(sessionId: string, values: Object): Promise { + async write(sessionId: string, values: Record): Promise { debug('redis store: writing session data %s, %O', sessionId, values) const message = new MessageBuilder().build(values, undefined, sessionId) @@ -61,7 +71,7 @@ export class RedisStore implements SessionStoreContract { } /** - * Cleanup session file by removing it + * Cleanup session by removing it */ async destroy(sessionId: string): Promise { debug('redis store: destroying session data %s', sessionId) @@ -75,4 +85,72 @@ export class RedisStore implements SessionStoreContract { debug('redis store: touching session data %s', sessionId) await this.#connection.expire(sessionId, this.#ttlSeconds) } + + /** + * Tag a session with a user ID + */ + async tag(sessionId: string, userId: string): Promise { + debug('redis store: tagging session %s with user %s', sessionId, userId) + await this.#connection.sadd(this.#getTagKey(userId), sessionId) + } + + /** + * Processes a single session result from the pipeline + */ + #processSessionResult(options: { sessionId: string; contents: string | null }): { + session: TaggedSession | null + isInvalid: boolean + } { + if (!options.contents) return { session: null, isInvalid: true } + + const data = this.#parseSessionData(options.contents, options.sessionId) + if (!data) return { session: null, isInvalid: true } + + return { session: { id: options.sessionId, data }, isInvalid: false } + } + + /** + * Fetches session contents for multiple session IDs using a pipeline + */ + async #fetchSessionContents(sessionIds: string[]): Promise> { + const pipeline = this.#connection.pipeline() + sessionIds.forEach((sessionId) => pipeline.get(sessionId)) + const results = await pipeline.exec() + + return results?.map((result) => result[1] as string | null) ?? [] + } + + /** + * Removes invalid session IDs from the user's tag set + */ + async #cleanupInvalidSessions(userId: string, invalidSessionIds: string[]): Promise { + if (invalidSessionIds.length === 0) return + + await this.#connection.srem(this.#getTagKey(userId), ...invalidSessionIds) + } + + /** + * Get all sessions for a given user ID (tag) + */ + async tagged(userId: string): Promise { + debug('redis store: getting sessions tagged with user %s', userId) + + const sessionIds = await this.#connection.smembers(this.#getTagKey(userId)) + if (sessionIds.length === 0) return [] + + const contents = await this.#fetchSessionContents(sessionIds) + + const results = sessionIds.map((sessionId, index) => + this.#processSessionResult({ sessionId, contents: contents[index] }) + ) + + const validSessions = results.filter((r) => r.session !== null).map((r) => r.session!) + const invalidSessionIds = results + .map((result, index) => (result.isInvalid ? sessionIds[index] : null)) + .filter((id) => id !== null) + + await this.#cleanupInvalidSessions(userId, invalidSessionIds) + + return validSessions + } } diff --git a/src/types.ts b/src/types.ts index 3c9474c..78dba24 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,14 @@ import type { DynamoDBClient, DynamoDBClientConfig } from '@aws-sdk/client-dynam export type AllowedSessionValues = string | boolean | number | object | Date | Array export type SessionData = Record +/** + * Represents a tagged session with its ID and data + */ +export interface TaggedSession { + id: string + data: SessionData +} + /** * Session stores must implement the session store contract. */ @@ -47,6 +55,22 @@ export interface SessionStoreContract { touch(sessionId: string): Promise | void } +/** + * Extended interface for stores that support tagging sessions + * (linking sessions to user IDs for example) + */ +export interface SessionStoreWithTaggingContract extends SessionStoreContract { + /** + * Tag a session with a user ID + */ + tag(sessionId: string, userId: string): Promise + + /** + * Get all sessions for a given user ID (tag) + */ + tagged(userId: string): Promise +} + /** * Base configuration for managing sessions without * stores. @@ -144,3 +168,11 @@ export type SessionStoreFactory = ( ctx: HttpContext, sessionConfig: SessionConfig ) => SessionStoreContract + +/** + * Resolved session config after processing by defineConfig + */ +export interface ResolvedSessionConfig extends SessionConfig { + store: string + stores: Record +} diff --git a/stubs/make/migration/sessions.stub b/stubs/make/migration/sessions.stub index 6837818..162c1eb 100644 --- a/stubs/make/migration/sessions.stub +++ b/stubs/make/migration/sessions.stub @@ -15,6 +15,7 @@ export default class extends BaseSchema { this.schema.createTable(this.tableName, (table) => { table.string('id').primary() table.text('data').notNullable() + table.string('user_id').nullable().index() table.timestamp('expires_at').notNullable().index() }) } diff --git a/tests/session_collection.spec.ts b/tests/session_collection.spec.ts new file mode 100644 index 0000000..4a18651 --- /dev/null +++ b/tests/session_collection.spec.ts @@ -0,0 +1,169 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { MemoryStore } from '../src/stores/memory.js' +import { SessionCollection } from '../src/session_collection.js' +import type { ResolvedSessionConfig, SessionStoreContract } from '../src/types.js' +import { E_SESSION_TAGGING_NOT_SUPPORTED } from '../src/errors.js' + +function createFakeConfig(store: SessionStoreContract): ResolvedSessionConfig { + return { + enabled: true, + cookieName: 'adonis_session', + clearWithBrowser: false, + age: '2h', + cookie: {}, + store: 'test', + stores: { test: () => store }, + } +} + +test.group('Session Collection', (group) => { + group.each.setup(() => { + return () => { + MemoryStore.sessions.clear() + MemoryStore.tags.clear() + } + }) + + test('get session data by id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1, name: 'John' }) + + const collection = new SessionCollection(createFakeConfig(store)) + + const data = await collection.get('session-1') + assert.deepEqual(data, { user: 1, name: 'John' }) + }) + + test('return null when session does not exist', async ({ assert }) => { + const store = new MemoryStore() + const collection = new SessionCollection(createFakeConfig(store)) + + const data = await collection.get('non-existent') + assert.isNull(data) + }) + + test('destroy a session by id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + store.write('session-2', { user: 2 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.destroy('session-1') + + assert.isNull(store.read('session-1')) + assert.isNotNull(store.read('session-2')) + }) +}) + +test.group('Session Collection | Tagging', (group) => { + group.each.setup(() => { + return () => { + MemoryStore.sessions.clear() + MemoryStore.tags.clear() + } + }) + + test('tag a session with a user id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.tag('session-1', 'user-123') + + assert.equal(MemoryStore.tags.get('session-1'), 'user-123') + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + store.write('session-2', { user: 1 }) + store.write('session-3', { user: 2 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.tag('session-1', 'user-1') + await collection.tag('session-2', 'user-1') + await collection.tag('session-3', 'user-2') + + const user1Sessions = await collection.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { user: 1 } }, + { id: 'session-2', data: { user: 1 } }, + ]) + + const user2Sessions = await collection.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { user: 2 } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const store = new MemoryStore() + const collection = new SessionCollection(createFakeConfig(store)) + + const sessions = await collection.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('supportsTagging returns true for stores with tag and tagged methods', async ({ + assert, + }) => { + const store = new MemoryStore() + const collection = new SessionCollection(createFakeConfig(store)) + + assert.isTrue(collection.supportsTagging()) + }) + + test('supportsTagging returns false for basic stores', async ({ assert }) => { + const basicStore: SessionStoreContract = { + read: () => null, + write: () => {}, + destroy: () => {}, + touch: () => {}, + } + + const collection = new SessionCollection(createFakeConfig(basicStore)) + assert.isFalse(collection.supportsTagging()) + }) + + test('throw error when tagging is not supported', async ({ assert }) => { + const basicStore: SessionStoreContract = { + read: () => null, + write: () => {}, + destroy: () => {}, + touch: () => {}, + } + + const collection = new SessionCollection(createFakeConfig(basicStore)) + + await assert.rejects( + () => collection.tag('session-1', 'user-1'), + // @ts-ignore Japa too strict + E_SESSION_TAGGING_NOT_SUPPORTED + ) + + // @ts-ignore Japa too strict + await assert.rejects(() => collection.tagged('user-1'), E_SESSION_TAGGING_NOT_SUPPORTED) + }) + + test('tagged excludes destroyed sessions', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + store.write('session-2', { user: 1 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.tag('session-1', 'user-1') + await collection.tag('session-2', 'user-1') + + await collection.destroy('session-1') + + const sessions = await collection.tagged('user-1') + assert.deepEqual(sessions, [{ id: 'session-2', data: { user: 1 } }]) + }) +}) diff --git a/tests/stores/database_store.spec.ts b/tests/stores/database_store.spec.ts index 8dd0a12..44171fc 100644 --- a/tests/stores/database_store.spec.ts +++ b/tests/stores/database_store.spec.ts @@ -69,6 +69,7 @@ test.group('Database store', (group) => { await db.connection().schema.createTable('sessions', (table) => { table.string('id').primary() table.text('data').notNullable() + table.string('user_id').nullable().index() table.timestamp('expires_at').notNullable() }) }) @@ -215,4 +216,71 @@ test.group('Database store', (group) => { const expiredRow = await db.from('sessions').where('id', 'expired-session').first() assert.exists(expiredRow) }).disableTimeout() + + test('tag a session with a user id', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-123') + + const row = await db.from('sessions').where('id', 'session-1').first() + assert.equal(row.user_id, 'user-123') + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.write('session-2', { message: 'world' }) + await store.write('session-3', { message: 'foo' }) + + await store.tag('session-1', 'user-1') + await store.tag('session-2', 'user-1') + await store.tag('session-3', 'user-2') + + const user1Sessions = await store.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { message: 'hello' } }, + { id: 'session-2', data: { message: 'world' } }, + ]) + + const user2Sessions = await store.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { message: 'foo' } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + const sessions = await store.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('re-tagging updates user id', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-1') + await store.tag('session-1', 'user-2') + + const row = await db.from('sessions').where('id', 'session-1').first() + assert.equal(row.user_id, 'user-2') + + const user1Sessions = await store.tagged('user-1') + assert.deepEqual(user1Sessions, []) + + const user2Sessions = await store.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-1', data: { message: 'hello' } }]) + }) + + test('tagged excludes expired sessions', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), 1, { gcProbability: 0 }) + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-1') + + await setTimeout(2000) + + const sessions = await store.tagged('user-1') + assert.deepEqual(sessions, []) + }).disableTimeout() }) diff --git a/tests/stores/memory_store.spec.ts b/tests/stores/memory_store.spec.ts index 3e75419..39193c7 100644 --- a/tests/stores/memory_store.spec.ts +++ b/tests/stores/memory_store.spec.ts @@ -12,7 +12,10 @@ import { MemoryStore } from '../../src/stores/memory.js' test.group('Memory store', (group) => { group.each.setup(() => { - return () => MemoryStore.sessions.clear() + return () => { + MemoryStore.sessions.clear() + MemoryStore.tags.clear() + } }) test('return null when session does not exists', async ({ assert }) => { @@ -72,4 +75,69 @@ test.group('Memory store', (group) => { assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) }) + + test('tag a session with a user id', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-123') + + assert.equal(MemoryStore.tags.get('session-1'), 'user-123') + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + session.write('session-2', { message: 'world' }) + session.write('session-3', { message: 'foo' }) + + await session.tag('session-1', 'user-1') + await session.tag('session-2', 'user-1') + await session.tag('session-3', 'user-2') + + const user1Sessions = await session.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { message: 'hello' } }, + { id: 'session-2', data: { message: 'world' } }, + ]) + + const user2Sessions = await session.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { message: 'foo' } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const session = new MemoryStore() + + const sessions = await session.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('destroy cleans up tag reference', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-1') + + session.destroy('session-1') + + assert.isFalse(MemoryStore.tags.has('session-1')) + + const sessions = await session.tagged('user-1') + assert.deepEqual(sessions, []) + }) + + test('tagged filters out destroyed sessions', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + session.write('session-2', { message: 'world' }) + await session.tag('session-1', 'user-1') + await session.tag('session-2', 'user-1') + + MemoryStore.sessions.delete('session-1') + + const sessions = await session.tagged('user-1') + assert.deepEqual(sessions, [{ id: 'session-2', data: { message: 'world' } }]) + }) }) diff --git a/tests/stores/redis_store.spec.ts b/tests/stores/redis_store.spec.ts index e5dd3f8..842550b 100644 --- a/tests/stores/redis_store.spec.ts +++ b/tests/stores/redis_store.spec.ts @@ -33,7 +33,12 @@ test.group('Redis store', (group) => { group.each.setup(() => { return async () => { - await redis.del(sessionId) + const sessionKeys = await redis.keys('session-*') + const tagKeys = await redis.keys('session_tag:*') + const testKeys = await redis.keys(sessionId) + + const allKeys = [...sessionKeys, ...tagKeys, ...testKeys] + if (allKeys.length > 0) await redis.del(...allKeys) } }) @@ -47,7 +52,7 @@ test.group('Redis store', (group) => { assert.isNull(value) }) - test('save session data in a set', async ({ assert }) => { + test('save session data', async ({ assert }) => { const session = new RedisStore(redis.connection('main'), '2 hours') await session.write(sessionId, { message: 'hello-world' }) @@ -111,4 +116,68 @@ test.group('Redis store', (group) => { const expiryPostTouch = await redis.ttl(sessionId) assert.isAtLeast(expiryPostTouch, 9) }).disableTimeout() + + test('tag a session with a user id', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + await session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-123') + + const members = await redis.smembers('session_tag:user-123') + assert.deepEqual(members, ['session-1']) + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + await session.write('session-1', { message: 'hello' }) + await session.write('session-2', { message: 'world' }) + await session.write('session-3', { message: 'foo' }) + + await session.tag('session-1', 'user-1') + await session.tag('session-2', 'user-1') + await session.tag('session-3', 'user-2') + + const user1Sessions = await session.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { message: 'hello' } }, + { id: 'session-2', data: { message: 'world' } }, + ]) + + const user2Sessions = await session.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { message: 'foo' } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + const sessions = await session.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('tagged filters out expired sessions', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 1) + + await session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-1') + + await setTimeout(2000) + + const sessions = await session.tagged('user-1') + assert.deepEqual(sessions, []) + }).disableTimeout() + + test('tagged cleans up expired sessions from tag set', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 1) + + await session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-1') + + await setTimeout(2000) + + await session.tagged('user-1') + + const members = await redis.smembers('session_tag:user-1') + assert.deepEqual(members, []) + }).disableTimeout() })