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:
2026-05-23 21:11:55 +02:00
parent e81c05a3db
commit 61f362004e
13 changed files with 1971 additions and 29 deletions
@@ -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);
});
});
});