@jskit-ai/rewarded-core 0.1.121 → 0.1.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@jskit-ai/rewarded-core",
3
- "version": "0.1.121",
3
+ "version": "0.1.123",
4
4
  "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
5
8
  "exports": {
6
9
  "./shared": "./src/shared/index.js",
7
10
  "./server/actions": "./src/server/actions.js",
@@ -116,15 +119,15 @@
116
119
  }
117
120
  },
118
121
  "dependencies": {
119
- "@jskit-ai/crud-core": "0.1.196",
120
- "@jskit-ai/database-runtime": "0.1.185",
121
- "@jskit-ai/json-rest-api-core": "0.1.129",
122
- "@jskit-ai/resource-crud-core": "0.1.127",
123
- "@jskit-ai/workspaces-core": "0.1.163"
122
+ "@jskit-ai/crud-core": "0.1.198",
123
+ "@jskit-ai/database-runtime": "0.1.187",
124
+ "@jskit-ai/json-rest-api-core": "0.1.131",
125
+ "@jskit-ai/resource-crud-core": "0.1.129",
126
+ "@jskit-ai/workspaces-core": "0.1.165"
124
127
  },
125
128
  "peerDependencies": {
126
- "@jskit-ai/auth-core": "0.1.183",
127
- "@jskit-ai/http-runtime": "0.1.183",
128
- "@jskit-ai/kernel": "0.1.185"
129
+ "@jskit-ai/auth-core": "0.1.185",
130
+ "@jskit-ai/http-runtime": "0.1.185",
131
+ "@jskit-ai/kernel": "0.1.187"
129
132
  }
130
133
  }
@@ -0,0 +1,240 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { toDatabaseDateTimeUtc } from "@jskit-ai/database-runtime/shared";
5
+ import { createCrudJsonApiRepository } from "@jskit-ai/crud-core/server/jsonApiRepository";
6
+ import {
7
+ addResourceIfMissing,
8
+ createJsonRestApiHost,
9
+ createJsonRestResourceScopeOptions
10
+ } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
11
+ import providerConfigsMigration from "../migrations/rewarded_provider_configs_initial.cjs";
12
+ import rulesMigration from "../migrations/rewarded_rules_initial.cjs";
13
+ import watchSessionsMigration from "../migrations/rewarded_watch_sessions_initial.cjs";
14
+ import unlockReceiptsMigration from "../migrations/rewarded_unlock_receipts_initial.cjs";
15
+ import { resource as providerConfigsResource } from "../src/shared/rewardedProviderConfigResource.js";
16
+ import { resource as rulesResource } from "../src/shared/rewardedRuleResource.js";
17
+ import { resource as watchSessionsResource } from "../src/shared/rewardedWatchSessionResource.js";
18
+ import { resource as unlockReceiptsResource } from "../src/shared/rewardedUnlockReceiptResource.js";
19
+ import { createService } from "../src/server/service.js";
20
+
21
+ const RESOURCES = [
22
+ ["rewardedProviderConfigs", providerConfigsResource, providerConfigsMigration],
23
+ ["rewardedRules", rulesResource, rulesMigration],
24
+ ["rewardedWatchSessions", watchSessionsResource, watchSessionsMigration],
25
+ ["rewardedUnlockReceipts", unlockReceiptsResource, unlockReceiptsMigration]
26
+ ];
27
+ const databaseUrl = process.env.REWARDED_TEST_DATABASE_URL;
28
+
29
+ function databaseOptions() {
30
+ if (!databaseUrl) {
31
+ return {
32
+ client: "better-sqlite3",
33
+ connection: { filename: ":memory:" },
34
+ useNullAsDefault: true,
35
+ pool: { min: 1, max: 1 }
36
+ };
37
+ }
38
+
39
+ const url = new URL(databaseUrl);
40
+ assert.equal(url.protocol, "mysql:", "Rewarded shipped migrations require MySQL.");
41
+ assert.match(url.pathname, /^\/jskit_rewarded_test_[a-z0-9_]+$/u, "Use an owned disposable rewarded test database.");
42
+ return {
43
+ client: "mysql2",
44
+ connection: {
45
+ host: url.hostname,
46
+ port: Number(url.port) || 3306,
47
+ user: decodeURIComponent(url.username),
48
+ password: decodeURIComponent(url.password),
49
+ database: url.pathname.slice(1),
50
+ ...(url.searchParams.has("socketPath") ? { socketPath: url.searchParams.get("socketPath") } : {}),
51
+ timezone: "Z"
52
+ },
53
+ pool: { min: 1, max: 1 }
54
+ };
55
+ }
56
+
57
+ function actorContext(workspaceId = "1", userId = "7", testCase = "") {
58
+ return {
59
+ actor: { id: userId },
60
+ scopeValues: { workspaceId, userId },
61
+ testCase
62
+ };
63
+ }
64
+
65
+ test(`rewarded grants use real resources and atomic transactions (${databaseUrl ? "MySQL shipped migrations" : "SQLite resource schema"})`, async (t) => {
66
+ const knex = knexLib(databaseOptions());
67
+ let ownsTables = false;
68
+ t.after(async () => {
69
+ try {
70
+ if (ownsTables) {
71
+ for (const [, , migration] of [...RESOURCES].reverse()) {
72
+ await migration.down(knex);
73
+ }
74
+ await knex.schema.dropTableIfExists("users");
75
+ await knex.schema.dropTableIfExists("workspaces");
76
+ }
77
+ } finally {
78
+ await knex.destroy();
79
+ }
80
+ });
81
+ const tables = [...RESOURCES.map(([, resource]) => resource.tableName), "users", "workspaces"];
82
+ for (const table of tables) {
83
+ assert.equal(await knex.schema.hasTable(table), false, `Refuse to reuse existing ${table}.`);
84
+ }
85
+ ownsTables = true;
86
+ for (const table of ["users", "workspaces"]) {
87
+ await knex.schema.createTable(table, (builder) => {
88
+ builder.bigInteger("id").unsigned().primary();
89
+ });
90
+ }
91
+ await knex("users").insert([{ id: 7 }, { id: 8 }]);
92
+ await knex("workspaces").insert([{ id: 1 }, { id: 2 }]);
93
+
94
+ const api = await createJsonRestApiHost({ knex });
95
+ const repositories = {};
96
+ for (const [scopeName, resource, migration] of RESOURCES) {
97
+ await addResourceIfMissing(api, scopeName, createJsonRestResourceScopeOptions(resource, {
98
+ writeSerializers: { "datetime-utc": toDatabaseDateTimeUtc }
99
+ }));
100
+ if (databaseUrl) {
101
+ await migration.up(knex);
102
+ } else {
103
+ // The shipped ON UPDATE defaults are MySQL-specific; SQLite verifies the resource workflow.
104
+ await api.resources[scopeName].createKnexTable();
105
+ }
106
+ repositories[scopeName] = createCrudJsonApiRepository({ api, resource, resourceScopeName: scopeName });
107
+ }
108
+
109
+ const context = actorContext();
110
+ const provider = await repositories.rewardedProviderConfigs.createDocument({
111
+ surface: "app", enabled: true, placement: "/test/rewarded", provider: "google-publisher-tag"
112
+ }, { context });
113
+ await repositories.rewardedRules.createDocument({
114
+ gateKey: "progress", surface: "app", enabled: true,
115
+ unlockMinutes: 30, cooldownMinutes: 0, dailyLimit: null,
116
+ title: "Log progress", description: "Rewarded workflow test"
117
+ }, { context });
118
+
119
+ const transactions = new Map();
120
+ const events = [];
121
+ let authorize = true;
122
+ let failReceipt = false;
123
+ const receiptFailure = new Error("Receipt storage hook failed");
124
+ await api.customize({
125
+ hooks: Object.fromEntries(["afterDataCallPatch", "afterDataCallPost", "afterCommit", "afterRollback"].map((event) => [event, {
126
+ functionName: `rewarded-integration-${event}`,
127
+ async handler({ scopeName, context: hookContext }) {
128
+ if (!hookContext.testCase) return;
129
+ assert.equal(hookContext.transaction, transactions.get(hookContext.testCase));
130
+ events.push({ event, scopeName, testCase: hookContext.testCase });
131
+ if (event === "afterDataCallPost" && scopeName === "rewardedUnlockReceipts" && failReceipt) {
132
+ const receipt = await hookContext.transaction("rewarded_unlock_receipts")
133
+ .where({ id: hookContext.id }).first();
134
+ assert.ok(receipt, "The receipt write must occur before injecting the rollback failure.");
135
+ throw receiptFailure;
136
+ }
137
+ }
138
+ }]))
139
+ });
140
+ const service = createService({
141
+ rewardedProviderConfigsRepository: repositories.rewardedProviderConfigs,
142
+ rewardedRulesRepository: repositories.rewardedRules,
143
+ rewardedWatchSessionsRepository: repositories.rewardedWatchSessions,
144
+ rewardedUnlockReceiptsRepository: repositories.rewardedUnlockReceipts,
145
+ async authorizeGrant({ session, context: grantContext, trx }) {
146
+ transactions.set(grantContext.testCase, trx);
147
+ const stored = await trx("rewarded_watch_sessions").where({ id: session.id }).first();
148
+ assert.equal(String(stored.user_id), grantContext.scopeValues.userId);
149
+ assert.equal(String(stored.workspace_id), grantContext.scopeValues.workspaceId);
150
+ assert.equal(events.some((entry) => entry.event === "afterCommit" && entry.testCase === grantContext.testCase), false);
151
+ return authorize;
152
+ }
153
+ });
154
+ const gate = { workspaceSlug: "alpha", gateKey: "progress" };
155
+ let successfulSession;
156
+
157
+ await t.test("grant commits the session and receipt with one shared hook transaction", async () => {
158
+ const started = await service.startGate(gate, { context });
159
+ successfulSession = started.session.id;
160
+ assert.equal(started.session.providerConfigId, provider.data.id);
161
+ const result = await service.grantReward({ ...gate, sessionId: successfulSession }, {
162
+ context: actorContext("1", "7", "success")
163
+ });
164
+ assert.equal(result.unlocked, true);
165
+ assert.equal(result.session.status, "rewarded");
166
+ assert.equal(result.unlock.watchSessionId, successfulSession);
167
+ assert.equal(result.unlock.providerConfigId, provider.data.id);
168
+ assert.ok(Date.parse(result.unlock.unlockedUntil) > Date.parse(result.unlock.grantedAt));
169
+ assert.deepEqual(events.filter((entry) => entry.testCase === "success").map(({ event, scopeName }) => [event, scopeName]), [
170
+ ["afterDataCallPatch", "rewardedWatchSessions"],
171
+ ["afterDataCallPost", "rewardedUnlockReceipts"],
172
+ ["afterCommit", "rewardedWatchSessions"],
173
+ ["afterCommit", "rewardedUnlockReceipts"]
174
+ ]);
175
+ assert.equal(transactions.get("success").isCompleted(), true);
176
+ assert.equal((await knex("rewarded_watch_sessions").where({ id: successfulSession }).first()).status, "rewarded");
177
+ assert.equal((await knex("rewarded_unlock_receipts").where({ watch_session_id: successfulSession }).select()).length, 1);
178
+ assert.equal((await service.getCurrentState(gate, { context })).reason, "already-unlocked");
179
+ });
180
+
181
+ await t.test("replaying a grant returns its existing receipt without another write", async () => {
182
+ const before = await knex("rewarded_unlock_receipts").where({ watch_session_id: successfulSession }).first();
183
+ const replay = await service.grantReward({ ...gate, sessionId: successfulSession }, {
184
+ context: actorContext("1", "7", "replay")
185
+ });
186
+ assert.equal(replay.unlock.id, String(before.id));
187
+ assert.equal((await knex("rewarded_unlock_receipts").select()).length, 1);
188
+ assert.deepEqual(events.filter((entry) => entry.testCase === "replay"), []);
189
+ });
190
+
191
+ await t.test("another user or workspace cannot grant or discover the owner's unlock", async () => {
192
+ for (const [workspaceId, userId] of [["1", "8"], ["2", "7"]]) {
193
+ const scopedContext = actorContext(workspaceId, userId, `denied-${workspaceId}-${userId}`);
194
+ await assert.rejects(service.grantReward({ ...gate, sessionId: successfulSession }, {
195
+ context: scopedContext
196
+ }), { statusCode: 404, transactionOutcome: "rolledBack" });
197
+ assert.equal(transactions.has(scopedContext.testCase), false, "Ownership denial must precede grant authorization.");
198
+ assert.equal((await repositories.rewardedUnlockReceipts.queryDocuments({}, { context: scopedContext })).data.length, 0);
199
+ }
200
+ assert.equal((await knex("rewarded_unlock_receipts").select()).length, 1);
201
+ });
202
+
203
+ await t.test("a receipt failure rolls back both persisted writes and allows a clean retry", async () => {
204
+ const session = await repositories.rewardedWatchSessions.createDocument({
205
+ gateKey: "progress", providerConfigId: provider.data.id, status: "started",
206
+ startedAt: new Date().toISOString()
207
+ }, { context });
208
+ const grant = { ...gate, sessionId: session.data.id };
209
+ authorize = false;
210
+ await assert.rejects(service.grantReward(grant, { context: actorContext("1", "7", "unauthorized") }), {
211
+ statusCode: 403, transactionOutcome: "rolledBack"
212
+ });
213
+ assert.equal((await knex("rewarded_watch_sessions").where({ id: session.data.id }).first()).status, "started");
214
+ authorize = true;
215
+ failReceipt = true;
216
+ await assert.rejects(service.grantReward(grant, { context: actorContext("1", "7", "rollback") }), (error) => {
217
+ assert.equal(error.transactionOutcome, "rolledBack");
218
+ assert.match(error.message, /Receipt storage hook failed/u);
219
+ return true;
220
+ });
221
+ const stored = await knex("rewarded_watch_sessions").where({ id: session.data.id }).first();
222
+ assert.equal(stored.status, "started");
223
+ assert.equal(stored.rewarded_at, null);
224
+ assert.equal(stored.completed_at, null);
225
+ assert.equal((await knex("rewarded_unlock_receipts").where({ watch_session_id: session.data.id }).select()).length, 0);
226
+ const rollbackEvents = events.filter((entry) => entry.testCase === "rollback");
227
+ assert.deepEqual(rollbackEvents.slice(0, 2).map(({ event, scopeName }) => [event, scopeName]), [
228
+ ["afterDataCallPatch", "rewardedWatchSessions"],
229
+ ["afterDataCallPost", "rewardedUnlockReceipts"]
230
+ ]);
231
+ assert.deepEqual(rollbackEvents.slice(2).map(({ event }) => event), ["afterRollback", "afterRollback"]);
232
+ assert.deepEqual(rollbackEvents.slice(2).map(({ scopeName }) => scopeName).sort(), [
233
+ "rewardedUnlockReceipts", "rewardedWatchSessions"
234
+ ]);
235
+ failReceipt = false;
236
+ const retry = await service.grantReward(grant, { context: actorContext("1", "7", "retry") });
237
+ assert.equal(retry.session.status, "rewarded");
238
+ assert.equal((await knex("rewarded_unlock_receipts").where({ watch_session_id: session.data.id }).select()).length, 1);
239
+ });
240
+ });