/** * Tests for PlayerPrivacyManager. * @module tests/unit/core/PlayerPrivacyManager.test */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PlayerPrivacyManager } from "../../../src/core/PlayerPrivacyManager.js"; import { createFoundryAdapterMock } from "../../helpers/foundryAdapterMock.js"; import { PRIVACY_SETTINGS_DEFAULT } from "../../../src/contracts/privacy-settings.js"; describe("PlayerPrivacyManager", () => { /** @type {import('../../../src/foundry/FoundryAdapter.js').FoundryAdapter} */ let adapter; /** @type {PlayerPrivacyManager} */ let manager; beforeEach(() => { adapter = createFoundryAdapterMock({ users: { get: vi.fn(), all: vi.fn(), }, }); }); afterEach(() => { vi.restoreAllMocks(); }); describe("constructor", () => { it("should construct with valid adapter", () => { expect(() => new PlayerPrivacyManager(adapter)).not.toThrow(); }); it("should throw TypeError for null adapter", () => { expect(() => new PlayerPrivacyManager(null)).toThrow(TypeError); expect(() => new PlayerPrivacyManager(null)).toThrow( "PlayerPrivacyManager: adapter argument is required and must be an object" ); }); it("should throw TypeError for undefined adapter", () => { expect(() => new PlayerPrivacyManager(undefined)).toThrow(TypeError); expect(() => new PlayerPrivacyManager(undefined)).toThrow( "PlayerPrivacyManager: adapter argument is required and must be an object" ); }); it("should throw TypeError for non-object adapter", () => { expect(() => new PlayerPrivacyManager("not an object")).toThrow(TypeError); expect(() => new PlayerPrivacyManager("not an object")).toThrow( "PlayerPrivacyManager: adapter argument is required and must be an object" ); }); it("should throw TypeError when adapter.users is not an object", () => { const badAdapter = { users: "not an object" }; expect(() => new PlayerPrivacyManager(badAdapter)).toThrow(TypeError); expect(() => new PlayerPrivacyManager(badAdapter)).toThrow( "PlayerPrivacyManager: adapter.users must be an object" ); }); it("should throw TypeError when adapter.users is null", () => { const badAdapter = { users: null }; expect(() => new PlayerPrivacyManager(badAdapter)).toThrow(TypeError); expect(() => new PlayerPrivacyManager(badAdapter)).toThrow( "PlayerPrivacyManager: adapter.users must be an object" ); }); it("should throw TypeError when adapter.users.get is not a function", () => { const badAdapter = { users: { get: "not a function", all: vi.fn() } }; expect(() => new PlayerPrivacyManager(badAdapter)).toThrow(TypeError); expect(() => new PlayerPrivacyManager(badAdapter)).toThrow( "PlayerPrivacyManager: adapter.users.get must be a function" ); }); it("should throw TypeError when adapter.users.all is not a function", () => { const badAdapter = { users: { get: vi.fn(), all: "not a function" } }; expect(() => new PlayerPrivacyManager(badAdapter)).toThrow(TypeError); expect(() => new PlayerPrivacyManager(badAdapter)).toThrow( "PlayerPrivacyManager: adapter.users.all must be a function" ); }); }); describe("getSettings", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); it("should return default settings when no flag exists", () => { adapter.users.get.mockReturnValue(null); const result = manager.getSettings("user1"); expect(result).toEqual(PRIVACY_SETTINGS_DEFAULT); }); it("should return saved settings when flag exists", () => { const savedSettings = { reactionCamEnabled: true, customPortraitFallback: null }; adapter.users.get.mockReturnValue({ getFlag: vi.fn((scope, key) => { if (scope === "scrying-pool") { return savedSettings[key]; } return undefined; }), }); const result = manager.getSettings("user1"); expect(result).toEqual(savedSettings); }); it("should return partial settings merged with defaults", () => { adapter.users.get.mockReturnValue({ getFlag: vi.fn((scope, key) => { if (scope === "scrying-pool" && key === "reactionCamEnabled") { return true; } return undefined; }), }); const result = manager.getSettings("user1"); expect(result).toEqual({ reactionCamEnabled: true, customPortraitFallback: null, }); }); it("should handle null user gracefully", () => { adapter.users.get.mockReturnValue(null); const result = manager.getSettings("nonexistent"); expect(result).toEqual(PRIVACY_SETTINGS_DEFAULT); }); it("should handle user without getFlag method", () => { adapter.users.get.mockReturnValue({}); const result = manager.getSettings("user1"); expect(result).toEqual(PRIVACY_SETTINGS_DEFAULT); }); }); describe("setSetting", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); it("should validate key is known", async () => { adapter.users.get.mockReturnValue({ setFlag: vi.fn().mockResolvedValue(undefined), }); await expect( manager.setSetting("user1", "invalidKey", true) ).rejects.toThrow(TypeError); await expect( manager.setSetting("user1", "invalidKey", true) ).rejects.toThrow("unknown key"); }); it("should validate value is boolean", async () => { adapter.users.get.mockReturnValue({ setFlag: vi.fn().mockResolvedValue(undefined), }); await expect( manager.setSetting("user1", "reactionCamEnabled", "not boolean") ).rejects.toThrow(TypeError); await expect( manager.setSetting("user1", "reactionCamEnabled", "not boolean") ).rejects.toThrow("value must be a boolean"); }); it("should call adapter.users.get with userId", async () => { const mockUser = { setFlag: vi.fn().mockResolvedValue(undefined) }; adapter.users.get.mockReturnValue(mockUser); await manager.setSetting("user1", "reactionCamEnabled", true); expect(adapter.users.get).toHaveBeenCalledWith("user1"); }); it("should call user.setFlag with correct parameters", async () => { const mockUser = { setFlag: vi.fn().mockResolvedValue(undefined) }; adapter.users.get.mockReturnValue(mockUser); await manager.setSetting("user1", "reactionCamEnabled", true); expect(mockUser.setFlag).toHaveBeenCalledWith( "scrying-pool", "reactionCamEnabled", true ); }); it("should throw when user does not exist", async () => { adapter.users.get.mockReturnValue(null); await expect( manager.setSetting("nonexistent", "reactionCamEnabled", true) ).rejects.toThrow(TypeError); await expect( manager.setSetting("nonexistent", "reactionCamEnabled", true) ).rejects.toThrow("User 'nonexistent' not found"); }); it("should throw when user does not have setFlag method", async () => { adapter.users.get.mockReturnValue({}); await expect( manager.setSetting("user1", "reactionCamEnabled", true) ).rejects.toThrow(TypeError); await expect( manager.setSetting("user1", "reactionCamEnabled", true) ).rejects.toThrow("User 'user1' does not support setFlag"); }); it("should emit change event after successful update", async () => { const mockUser = { setFlag: vi.fn().mockResolvedValue(undefined) }; adapter.users.get.mockReturnValue(mockUser); // First call to getSettings returns false (default), then we set to true adapter.users.get.mockReturnValue({ getFlag: vi.fn(() => false), setFlag: vi.fn().mockResolvedValue(undefined), }); const callback = vi.fn(); manager.onChange(callback); await manager.setSetting("user1", "reactionCamEnabled", true); expect(callback).toHaveBeenCalledWith("user1", "reactionCamEnabled", true, false); }); }); describe("isOptedIn", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); it("should throw for unknown feature", () => { adapter.users.get.mockReturnValue({ getFlag: vi.fn(() => false), }); expect(() => manager.isOptedIn("user1", "invalidFeature")).toThrow(TypeError); expect(() => manager.isOptedIn("user1", "invalidFeature")).toThrow("unknown feature"); }); it("should return true when setting is enabled", () => { adapter.users.get.mockReturnValue({ getFlag: vi.fn((scope, key) => { if (key === "reactionCamEnabled") return true; return false; }), }); expect(manager.isOptedIn("user1", "reactionCam")).toBe(true); }); it("should return false when setting is disabled", () => { adapter.users.get.mockReturnValue({ getFlag: vi.fn((scope, key) => { if (key === "reactionCamEnabled") return false; return false; }), }); expect(manager.isOptedIn("user1", "reactionCam")).toBe(false); }); it("should return false when setting is not found (defaults to false)", () => { adapter.users.get.mockReturnValue({ getFlag: vi.fn(() => undefined), }); expect(manager.isOptedIn("user1", "reactionCam")).toBe(false); }); it("should return false for non-existent user", () => { adapter.users.get.mockReturnValue(null); expect(manager.isOptedIn("nonexistent", "reactionCam")).toBe(false); }); }); describe("getAllSettings", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); it("should return empty Map when no users", () => { adapter.users.all.mockReturnValue([]); const result = manager.getAllSettings(); expect(result).toEqual(new Map()); }); it("should aggregate settings from all users", () => { const user1 = { id: "user1", getFlag: vi.fn((scope, key) => { if (key === "reactionCamEnabled") return true; return undefined; // customPortraitFallback and other keys }), }; const user2 = { id: "user2", getFlag: vi.fn((scope, key) => { if (key === "reactionCamEnabled") return false; return undefined; // customPortraitFallback and other keys }), }; adapter.users.all.mockReturnValue([user1, user2]); adapter.users.get.mockImplementation((id) => { if (id === "user1") return user1; if (id === "user2") return user2; return null; }); const result = manager.getAllSettings(); expect(result.size).toBe(2); expect(result.get("user1")).toEqual({ reactionCamEnabled: true, customPortraitFallback: null, }); expect(result.get("user2")).toEqual({ reactionCamEnabled: false, customPortraitFallback: null, }); }); it("should skip users without getFlag method", () => { const user1 = { id: "user1", getFlag: vi.fn(() => true), }; const user2 = { id: "user2" }; adapter.users.all.mockReturnValue([user1, user2]); adapter.users.get.mockImplementation((id) => { if (id === "user1") return user1; if (id === "user2") return user2; return null; }); const result = manager.getAllSettings(); expect(result.size).toBe(1); expect(result.has("user1")).toBe(true); expect(result.has("user2")).toBe(false); }); }); describe("onChange", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); it("should allow multiple subscribers", () => { const callback1 = vi.fn(); const callback2 = vi.fn(); manager.onChange(callback1); manager.onChange(callback2); const mockUser = { setFlag: vi.fn().mockResolvedValue(undefined) }; adapter.users.get.mockReturnValue(mockUser); return manager.setSetting("user1", "reactionCamEnabled", true).then(() => { expect(callback1).toHaveBeenCalled(); expect(callback2).toHaveBeenCalled(); }); }); it("should pass correct parameters to subscribers", async () => { const callback = vi.fn(); manager.onChange(callback); const mockUser = { getFlag: vi.fn(() => false), setFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); await manager.setSetting("user1", "reactionCamEnabled", true); expect(callback).toHaveBeenCalledWith("user1", "reactionCamEnabled", true, false); }); it("should allow unsubscribing", async () => { const callback = vi.fn(); const unsubscribe = manager.onChange(callback); const mockUser = { getFlag: vi.fn(() => false), setFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); // Subscribe, unsubscribe, then set setting unsubscribe(); await manager.setSetting("user1", "reactionCamEnabled", true); expect(callback).not.toHaveBeenCalled(); }); }); describe("Integration scenarios", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); it("should handle player enabling Reaction Cam", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => false), setFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); // Initially opted out expect(manager.isOptedIn("player1", "reactionCam")).toBe(false); // Enable Reaction Cam await manager.setSetting("player1", "reactionCamEnabled", true); // After setting, the mock should return true for reactionCamEnabled adapter.users.get.mockReturnValue({ id: "player1", getFlag: vi.fn((scope, key) => { if (key === "reactionCamEnabled") return true; return false; }), setFlag: vi.fn().mockResolvedValue(undefined), }); expect(manager.isOptedIn("player1", "reactionCam")).toBe(true); }); it("should allow GM to view all players' settings", () => { const gm = { id: "gm1", isGM: true, getFlag: vi.fn(() => false) }; const player1 = { id: "player1", getFlag: vi.fn((scope, key) => (key === "reactionCamEnabled" ? true : false)), }; const player2 = { id: "player2", getFlag: vi.fn((scope, key) => (key === "reactionCamEnabled" ? false : false)), }; adapter.users.all.mockReturnValue([gm, player1, player2]); adapter.users.get.mockImplementation((id) => { if (id === "gm1") return gm; if (id === "player1") return player1; if (id === "player2") return player2; return null; }); const allSettings = manager.getAllSettings(); expect(allSettings.size).toBe(3); }); }); // ==================== PORTRAIT FALLBACK TESTS ==================== describe("portrait fallback methods", () => { beforeEach(() => { manager = new PlayerPrivacyManager(adapter); }); describe("setPortraitFallback", () => { it("should validate DataURL format", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => null), setFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); const invalidDataURL = "https://example.com/image.png"; await expect( manager.setPortraitFallback("player1", invalidDataURL) ).rejects.toThrow(TypeError); }); it("should accept valid PNG DataURL", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => null), setFlag: vi.fn().mockResolvedValue(undefined), unsetFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; await expect( manager.setPortraitFallback("player1", dataURL) ).resolves.not.toThrow(); expect(mockUser.setFlag).toHaveBeenCalledWith( "scrying-pool", "customPortraitFallback", dataURL ); }); it("should emit change event with type 'portrait'", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => null), setFlag: vi.fn().mockResolvedValue(undefined), unsetFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); const changeCallback = vi.fn(); manager.onChange(changeCallback); const dataURL = "data:image/png;base64,test"; await manager.setPortraitFallback("player1", dataURL); expect(changeCallback).toHaveBeenCalled(); // Check that the callback was called with portrait-related parameters const calls = changeCallback.mock.calls; expect(calls.length).toBeGreaterThan(0); }); it("should reject invalid MIME type", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => null), setFlag: vi.fn().mockResolvedValue(undefined), unsetFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); const svgDataURL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg=="; await expect( manager.setPortraitFallback("player1", svgDataURL) ).rejects.toThrow(TypeError); }); it("should throw TypeError for non-existent user", async () => { adapter.users.get.mockReturnValue(null); const dataURL = "data:image/png;base64,test"; await expect( manager.setPortraitFallback("nonexistent", dataURL) ).rejects.toThrow(TypeError); }); }); describe("getPortraitFallback", () => { it("should return null when no custom portrait is set", () => { const mockUser = { id: "player1", getFlag: vi.fn((scope, key) => { if (key === "customPortraitFallback") return undefined; return undefined; }), }; adapter.users.get.mockReturnValue(mockUser); const result = manager.getPortraitFallback("player1"); expect(result).toBeNull(); }); it("should return DataURL when custom portrait is set", () => { const dataURL = "data:image/png;base64,test123"; const mockUser = { id: "player1", getFlag: vi.fn((scope, key) => { if (key === "customPortraitFallback") return dataURL; return undefined; }), }; adapter.users.get.mockReturnValue(mockUser); const result = manager.getPortraitFallback("player1"); expect(result).toBe(dataURL); }); it("should return null for non-existent user", () => { adapter.users.get.mockReturnValue(null); const result = manager.getPortraitFallback("nonexistent"); expect(result).toBeNull(); }); }); describe("getPortraitFallbackDataURL", () => { it("should return DataURL directly", () => { const dataURL = "data:image/png;base64,test123"; const mockUser = { id: "player1", getFlag: vi.fn((scope, key) => { if (key === "customPortraitFallback") return dataURL; return undefined; }), }; adapter.users.get.mockReturnValue(mockUser); const result = manager.getPortraitFallbackDataURL("player1"); expect(result).toBe(dataURL); }); it("should return null when not set", () => { const mockUser = { id: "player1", getFlag: vi.fn(() => undefined), }; adapter.users.get.mockReturnValue(mockUser); const result = manager.getPortraitFallbackDataURL("player1"); expect(result).toBeNull(); }); }); describe("removePortraitFallback", () => { it("should call unsetFlag for customPortraitFallback", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => "data:image/png;base64,old"), unsetFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); await manager.removePortraitFallback("player1"); expect(mockUser.unsetFlag).toHaveBeenCalledWith( "scrying-pool", "customPortraitFallback" ); }); it("should emit change event with type 'portrait'", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => "data:image/png;base64,old"), unsetFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); const changeCallback = vi.fn(); manager.onChange(changeCallback); await manager.removePortraitFallback("player1"); expect(changeCallback).toHaveBeenCalled(); }); it("should throw TypeError for non-existent user", async () => { adapter.users.get.mockReturnValue(null); await expect( manager.removePortraitFallback("nonexistent") ).rejects.toThrow(TypeError); }); }); describe("getSettings with portrait fallback", () => { it("should include customPortraitFallback in returned settings", () => { const dataURL = "data:image/png;base64,test"; const mockUser = { id: "player1", getFlag: vi.fn((scope, key) => { if (key === "customPortraitFallback") return dataURL; if (key === "reactionCamEnabled") return true; return undefined; }), }; adapter.users.get.mockReturnValue(mockUser); const settings = manager.getSettings("player1"); expect(settings).toHaveProperty("customPortraitFallback"); expect(settings.customPortraitFallback).toBe(dataURL); }); }); describe("setSetting rejection of customPortraitFallback", () => { it("should reject customPortraitFallback key in setSetting", async () => { const mockUser = { id: "player1", getFlag: vi.fn(() => null), setFlag: vi.fn().mockResolvedValue(undefined), }; adapter.users.get.mockReturnValue(mockUser); const dataURL = "data:image/png;base64,test"; await expect( manager.setSetting("player1", "customPortraitFallback", dataURL) ).rejects.toThrow(TypeError); }); }); }); });