Story 4.1: Task 1 Complete - PlayerPrivacyManager Core Logic
- Created src/contracts/privacy-settings.js with: - PrivacySettings typedef - PRIVACY_SETTINGS_DEFAULT (both flags false) - PRIVACY_SETTING_KEYS and FEATURE_NAME_MAP constants - createPrivacySettings() factory - isValidPrivacySettings() validator - validateSettingKey(), validateSettingValue(), validateFeatureName() helpers - Created src/core/PlayerPrivacyManager.js with: - Constructor with FoundryAdapter DI validation - getSettings(userId) - retrieves settings from user flags - setSetting(userId, key, value) - async, validates, persists via user.setFlag - isOptedIn(userId, feature) - convenience method for feature checks - getAllSettings() - aggregates all users' settings (GM view) - onChange(callback) - subscription pattern for change events - teardown() - cleanup - Created tests/unit/contracts/privacy-settings.test.js - 44 tests - Created tests/unit/core/PlayerPrivacyManager.test.js - 35 tests - All tests passing, lint clean - Updated sprint-status.yaml: 4-1 from ready-for-dev to in-progress - Updated story file: Task 1 subtasks 1.1-1.8 marked complete Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* 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, hpReactiveCamStylingEnabled: false };
|
||||
adapter.users.get.mockReturnValue({
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (scope === "video-view-manager") {
|
||||
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 === "video-view-manager" && key === "reactionCamEnabled") {
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
const result = manager.getSettings("user1");
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
"video-view-manager",
|
||||
"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);
|
||||
});
|
||||
|
||||
it("should work for hpReactiveCamStyling feature", () => {
|
||||
adapter.users.get.mockReturnValue({
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
expect(manager.isOptedIn("user1", "hpReactiveCamStyling")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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 false;
|
||||
}),
|
||||
};
|
||||
const user2 = {
|
||||
id: "user2",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
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,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
});
|
||||
expect(result.get("user2")).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 handle player disabling HP-Reactive Cam Styling", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return false;
|
||||
}),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
// Initially opted in
|
||||
expect(manager.isOptedIn("player1", "hpReactiveCamStyling")).toBe(true);
|
||||
|
||||
// Disable HP-Reactive Cam Styling
|
||||
await manager.setSetting("player1", "hpReactiveCamStylingEnabled", false);
|
||||
// After setting, the mock should return false for hpReactiveCamStylingEnabled
|
||||
adapter.users.get.mockReturnValue({
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return false;
|
||||
return false;
|
||||
}),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
expect(manager.isOptedIn("player1", "hpReactiveCamStyling")).toBe(false);
|
||||
});
|
||||
|
||||
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 === "hpReactiveCamStylingEnabled" ? true : 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user