diff --git a/src/lib/access.js b/src/lib/access.js new file mode 100644 index 0000000..c315c69 --- /dev/null +++ b/src/lib/access.js @@ -0,0 +1,78 @@ +"use strict"; + +class AudioAccessStore { + constructor() { + this.grants = new Map(); + } + + _key(audioId) { + return String(audioId); + } + + hasGrant(audioId, userId) { + const key = this._key(audioId); + const set = this.grants.get(key); + return Boolean(set && set.has(userId)); + } + + grantAccess(audioId, userId) { + const key = this._key(audioId); + if (!this.grants.has(key)) { + this.grants.set(key, new Set()); + } + this.grants.get(key).add(userId); + } + + canAccess({ audio, userId }) { + if (!audio || !audio.id) { + throw new Error("audio_required"); + } + + if (!userId) { + return { allowed: false, reason: "auth_required" }; + } + + if (audio.ownerUserId === userId) { + return { allowed: true, reason: "owner" }; + } + + if (this.hasGrant(audio.id, userId)) { + return { allowed: true, reason: "grant" }; + } + + return { + allowed: false, + reason: "payment_required", + creditsRequired: audio.creditsCharged, + }; + } + + unlockWithCredits({ audio, userId, wallet }) { + if (!audio || !audio.id) { + throw new Error("audio_required"); + } + + if (!userId) { + throw new Error("auth_required"); + } + + if (audio.ownerUserId === userId || this.hasGrant(audio.id, userId)) { + return { unlocked: true, charged: 0 }; + } + + wallet.applyTransaction({ + userId, + type: "debit", + amount: audio.creditsCharged, + reason: "audio_unlock", + idempotencyKey: `unlock:${audio.id}:${userId}`, + }); + + this.grantAccess(audio.id, userId); + return { unlocked: true, charged: audio.creditsCharged }; + } +} + +module.exports = { + AudioAccessStore, +}; diff --git a/test/access.test.js b/test/access.test.js new file mode 100644 index 0000000..4dfa9b7 --- /dev/null +++ b/test/access.test.js @@ -0,0 +1,67 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { WalletStore } = require("../src/lib/wallet"); +const { AudioAccessStore } = require("../src/lib/access"); + +const audio = { + id: "a1", + ownerUserId: "owner-1", + creditsCharged: 3, +}; + +test("owner can access without payment", () => { + const access = new AudioAccessStore(); + const result = access.canAccess({ audio, userId: "owner-1" }); + assert.equal(result.allowed, true); + assert.equal(result.reason, "owner"); +}); + +test("non-owner requires payment", () => { + const access = new AudioAccessStore(); + const result = access.canAccess({ audio, userId: "u2" }); + assert.equal(result.allowed, false); + assert.equal(result.reason, "payment_required"); + assert.equal(result.creditsRequired, 3); +}); + +test("unlock debits same credits and creates permanent grant", () => { + const access = new AudioAccessStore(); + const wallet = new WalletStore(); + + wallet.applyTransaction({ + userId: "u2", + type: "credit", + amount: 10, + reason: "topup", + idempotencyKey: "credit-u2", + }); + + const unlocked = access.unlockWithCredits({ audio, userId: "u2", wallet }); + assert.equal(unlocked.unlocked, true); + assert.equal(unlocked.charged, 3); + assert.equal(wallet.getBalance("u2"), 7); + + const canAccess = access.canAccess({ audio, userId: "u2" }); + assert.equal(canAccess.allowed, true); + assert.equal(canAccess.reason, "grant"); +}); + +test("second unlock call is idempotent and does not double charge", () => { + const access = new AudioAccessStore(); + const wallet = new WalletStore(); + + wallet.applyTransaction({ + userId: "u2", + type: "credit", + amount: 10, + reason: "topup", + idempotencyKey: "credit-u2-2", + }); + + access.unlockWithCredits({ audio, userId: "u2", wallet }); + access.unlockWithCredits({ audio, userId: "u2", wallet }); + + assert.equal(wallet.getBalance("u2"), 7); +});