diff --git a/packages/public-api/src/apis/reddit/models/User.ts b/packages/public-api/src/apis/reddit/models/User.ts index 17e8a2de..83d58932 100644 --- a/packages/public-api/src/apis/reddit/models/User.ts +++ b/packages/public-api/src/apis/reddit/models/User.ts @@ -55,6 +55,47 @@ export type ModeratorPermission = | 'channels' | 'community_chat'; +export type UserAccountStatus = 'active' | 'locked' | 'suspended' | 'unknown'; + +type UserAccountStatusData = { + accountStatus?: UserAccountStatus | string | undefined; + isAccountLocked?: boolean | null | undefined; + isLocked?: boolean | null | undefined; + isSuspended?: boolean | null | undefined; +}; + +type UserProtoWithAccountStatus = UserProto & UserAccountStatusData; + +function normalizeUserAccountStatus(data: UserAccountStatusData): UserAccountStatus { + const accountStatus = + typeof data.accountStatus === 'string' + ? data.accountStatus.toLowerCase().replace(/^user_account_status_/, '') + : undefined; + + if ( + accountStatus === 'active' || + accountStatus === 'locked' || + accountStatus === 'suspended' || + accountStatus === 'unknown' + ) { + return accountStatus; + } + + if (data.isAccountLocked === true || data.isLocked === true) { + return 'locked'; + } + + if (data.isSuspended === true) { + return 'suspended'; + } + + if (data.isAccountLocked === false || data.isLocked === false || data.isSuspended === false) { + return 'active'; + } + + return 'unknown'; +} + export type CreateRelationshipOptions = { subredditName: string; username: string; @@ -220,6 +261,7 @@ export class User { #hasVerifiedEmail: boolean; #displayName: string; #about: string; + #accountStatus: UserAccountStatus; #metadata: Metadata | undefined; @@ -227,7 +269,7 @@ export class User { * @internal */ constructor( - data: UserProto & { modPermissions?: { [subredditName: string]: string[] } }, + data: UserProtoWithAccountStatus & { modPermissions?: { [subredditName: string]: string[] } }, metadata: Metadata | undefined ) { makeGettersEnumerable(this); @@ -264,6 +306,7 @@ export class User { this.#displayName = data.subreddit?.title ?? this.#username; this.#about = data.subreddit?.publicDescription ?? ''; + this.#accountStatus = normalizeUserAccountStatus(data); this.#metadata = metadata; } @@ -376,7 +419,32 @@ export class User { return this.#about; } - toJSON(): Pick & { + /** + * The user's account availability status, when provided by the Reddit API. + * + * `unknown` means the current API response did not include enough information to distinguish the + * account from an active account. + */ + get accountStatus(): UserAccountStatus { + return this.#accountStatus; + } + + /** Whether the user's account is locked for account-access or security reasons. */ + get isAccountLocked(): boolean { + return this.#accountStatus === 'locked'; + } + + toJSON(): Pick< + User, + | 'id' + | 'username' + | 'createdAt' + | 'linkKarma' + | 'commentKarma' + | 'nsfw' + | 'accountStatus' + | 'isAccountLocked' + > & { modPermissionsBySubreddit: Record; } { return { @@ -386,6 +454,8 @@ export class User { linkKarma: this.linkKarma, commentKarma: this.commentKarma, nsfw: this.nsfw, + accountStatus: this.accountStatus, + isAccountLocked: this.isAccountLocked, modPermissionsBySubreddit: Object.fromEntries(this.modPermissions), }; } diff --git a/packages/public-api/src/apis/reddit/tests/user.api.test.ts b/packages/public-api/src/apis/reddit/tests/user.api.test.ts index ba9c7598..5f638041 100644 --- a/packages/public-api/src/apis/reddit/tests/user.api.test.ts +++ b/packages/public-api/src/apis/reddit/tests/user.api.test.ts @@ -318,5 +318,37 @@ describe('User API', () => { expect(user.modPermissions).toEqual(understoodPermissions); }); + + test('accountStatus distinguishes locked users', () => { + const user = new User( + { + id: 'someID', + name: username, + createdUtc: Date.now(), + snoovatarSize: [1], + isAccountLocked: true, + }, + api.metadata + ); + + expect(user.accountStatus).toBe('locked'); + expect(user.isAccountLocked).toBe(true); + }); + + test('accountStatus falls back to suspended when lock status is unavailable', () => { + const user = new User( + { + id: 'someID', + name: username, + createdUtc: Date.now(), + snoovatarSize: [1], + isSuspended: true, + }, + api.metadata + ); + + expect(user.accountStatus).toBe('suspended'); + expect(user.isAccountLocked).toBe(false); + }); }); }); diff --git a/packages/reddit/src/mocks/UserMock.ts b/packages/reddit/src/mocks/UserMock.ts index c6883287..b2328d3c 100644 --- a/packages/reddit/src/mocks/UserMock.ts +++ b/packages/reddit/src/mocks/UserMock.ts @@ -31,10 +31,23 @@ import type { Users } from '@devvit/protos/types/devvit/plugin/redditapi/users/u import type { PluginMock } from '@devvit/shared-types/test/index.js'; import { isT2, T2 } from '@devvit/shared-types/tid.js'; +import type { UserAccountStatus } from '../models/User.js'; + type Username = string; +type UserAccountStatusFields = { + accountStatus?: UserAccountStatus | undefined; + isAccountLocked?: boolean | undefined; + isLocked?: boolean | undefined; +}; + +type UserWithAccountStatus = User & UserAccountStatusFields; + +type UserDataByAccountIdsResponseWithAccountStatus = + UserDataByAccountIdsResponse_UserAccountData & UserAccountStatusFields; + type UserStore = { - users: Map; + users: Map; }; export class UserPluginMock implements Users { @@ -58,7 +71,7 @@ export class UserPluginMock implements Users { _metadata?: Metadata ): Promise { const ids = request.ids.split(','); - const responseUsers: { [key: string]: UserDataByAccountIdsResponse_UserAccountData } = {}; + const responseUsers: { [key: string]: UserDataByAccountIdsResponseWithAccountStatus } = {}; for (const id of ids) { const targetId = T2(isT2(id) ? id : `t2_${id}`); @@ -78,6 +91,8 @@ export class UserPluginMock implements Users { linkKarma: user.linkKarma, commentKarma: user.commentKarma, profileOver18: user.over18, + accountStatus: user.accountStatus, + isAccountLocked: user.isAccountLocked ?? user.isLocked, }; } } @@ -200,8 +215,10 @@ export class UserMock implements PluginMock { * Seeds the mock database with a User. * This allows tests to set up state before calling `reddit.getUserByUsername`. */ - addUser(data: Omit, 'id'> & { name: string; id: T2 }): User { - const user: User = { + addUser( + data: Omit, 'id'> & { name: string; id: T2 } + ): UserWithAccountStatus { + const user: UserWithAccountStatus = { createdUtc: data.createdUtc ?? Math.floor(Date.now() / 1000), linkKarma: data.linkKarma ?? 0, commentKarma: data.commentKarma ?? 0, @@ -221,6 +238,8 @@ export class UserMock implements PluginMock { prefShowSnoovatar: data.prefShowSnoovatar ?? true, snoovatarSize: data.snoovatarSize ?? [], ...data, + accountStatus: data.accountStatus, + isAccountLocked: data.isAccountLocked ?? data.isLocked ?? false, id: data.id.replace(/^t2_/, ''), }; diff --git a/packages/reddit/src/models/User.ts b/packages/reddit/src/models/User.ts index 37ca16ea..48147214 100644 --- a/packages/reddit/src/models/User.ts +++ b/packages/reddit/src/models/User.ts @@ -52,6 +52,47 @@ export type ModeratorPermission = | 'channels' | 'community_chat'; +export type UserAccountStatus = 'active' | 'locked' | 'suspended' | 'unknown'; + +type UserAccountStatusData = { + accountStatus?: UserAccountStatus | string | undefined; + isAccountLocked?: boolean | null | undefined; + isLocked?: boolean | null | undefined; + isSuspended?: boolean | null | undefined; +}; + +type UserProtoWithAccountStatus = UserProto & UserAccountStatusData; + +function normalizeUserAccountStatus(data: UserAccountStatusData): UserAccountStatus { + const accountStatus = + typeof data.accountStatus === 'string' + ? data.accountStatus.toLowerCase().replace(/^user_account_status_/, '') + : undefined; + + if ( + accountStatus === 'active' || + accountStatus === 'locked' || + accountStatus === 'suspended' || + accountStatus === 'unknown' + ) { + return accountStatus; + } + + if (data.isAccountLocked === true || data.isLocked === true) { + return 'locked'; + } + + if (data.isSuspended === true) { + return 'suspended'; + } + + if (data.isAccountLocked === false || data.isLocked === false || data.isSuspended === false) { + return 'active'; + } + + return 'unknown'; +} + export type CreateRelationshipOptions = { subredditName: string; username: string; @@ -231,11 +272,12 @@ export class User { #hasVerifiedEmail: boolean; #displayName: string; #about: string; + #accountStatus: UserAccountStatus; /** * @internal */ - constructor(data: UserProto & { modPermissions?: { [subredditName: string]: string[] } }) { + constructor(data: UserProtoWithAccountStatus & { modPermissions?: { [subredditName: string]: string[] } }) { makeGettersEnumerable(this); assertNonNull(data.id, 'User ID is missing or undefined'); @@ -270,6 +312,7 @@ export class User { this.#displayName = data.subreddit?.title ?? this.#username; this.#about = data.subreddit?.publicDescription ?? ''; + this.#accountStatus = normalizeUserAccountStatus(data); } /** @@ -378,7 +421,32 @@ export class User { return this.#about; } - toJSON(): Pick & { + /** + * The user's account availability status, when provided by the Reddit API. + * + * `unknown` means the current API response did not include enough information to distinguish the + * account from an active account. + */ + get accountStatus(): UserAccountStatus { + return this.#accountStatus; + } + + /** Whether the user's account is locked for account-access or security reasons. */ + get isAccountLocked(): boolean { + return this.#accountStatus === 'locked'; + } + + toJSON(): Pick< + User, + | 'id' + | 'username' + | 'createdAt' + | 'linkKarma' + | 'commentKarma' + | 'nsfw' + | 'accountStatus' + | 'isAccountLocked' + > & { modPermissionsBySubreddit: Record; } { return { @@ -388,6 +456,8 @@ export class User { linkKarma: this.linkKarma, commentKarma: this.commentKarma, nsfw: this.nsfw, + accountStatus: this.accountStatus, + isAccountLocked: this.isAccountLocked, modPermissionsBySubreddit: Object.fromEntries(this.modPermissions), }; } @@ -774,6 +844,8 @@ async function listingProtosToUsers( // because of how we defined the UserDataByAccountIdsResponse_UserAccountData protobuf. assertNonNull(userData as unknown, 'User data is missing from response'); + const userAccountStatusData = userData as UserAccountStatusData; + return new User({ id, name: userData.name, @@ -781,6 +853,8 @@ async function listingProtosToUsers( commentKarma: userData.commentKarma, createdUtc: userData.createdUtc, over18: userData.profileOver18, + accountStatus: userAccountStatusData.accountStatus, + isAccountLocked: userAccountStatusData.isAccountLocked ?? userAccountStatusData.isLocked, snoovatarSize: [], modPermissions: { [subredditName]: child.data?.modPermissions ?? [], diff --git a/packages/reddit/src/tests/user.api.test.ts b/packages/reddit/src/tests/user.api.test.ts index 9edb522b..4cf9efee 100644 --- a/packages/reddit/src/tests/user.api.test.ts +++ b/packages/reddit/src/tests/user.api.test.ts @@ -208,5 +208,31 @@ describe('User API', () => { expect(user.modPermissions).toStrictEqual(understoodPermissions); }); + + test('accountStatus distinguishes locked users', () => { + const user = new User({ + id: 'someID', + name: username, + createdUtc: Date.now(), + snoovatarSize: [1], + isAccountLocked: true, + }); + + expect(user.accountStatus).toBe('locked'); + expect(user.isAccountLocked).toBe(true); + }); + + test('accountStatus falls back to suspended when lock status is unavailable', () => { + const user = new User({ + id: 'someID', + name: username, + createdUtc: Date.now(), + snoovatarSize: [1], + isSuspended: true, + }); + + expect(user.accountStatus).toBe('suspended'); + expect(user.isAccountLocked).toBe(false); + }); }); }); diff --git a/packages/test/src/server/vitest/devvitTest.reddit.test.ts b/packages/test/src/server/vitest/devvitTest.reddit.test.ts index de8451f0..9defadf3 100644 --- a/packages/test/src/server/vitest/devvitTest.reddit.test.ts +++ b/packages/test/src/server/vitest/devvitTest.reddit.test.ts @@ -40,6 +40,19 @@ test('can retrieve current user', async () => { expect(user?.id).toBe('t2_testuser'); }); +test('can mock and retrieve a locked user', async ({ mocks }) => { + mocks.reddit.users.addUser({ + id: 't2_locked_user', + name: 'locked_user', + isAccountLocked: true, + }); + + const user = await reddit.getUserByUsername('locked_user'); + expect(user).toBeDefined(); + expect(user?.accountStatus).toBe('locked'); + expect(user?.isAccountLocked).toBe(true); +}); + test('can retrieve current username', async () => { const username = await reddit.getCurrentUsername(); expect(username).toBe('testuser');