Track pending browsing upvotes

This commit is contained in:
jjbrunton committed 2026-08-11 22:05:54 +01:00
1 parent 540590f43e
commit 1fd154e1eb
13 files changed
+73 -27

No files matched your search

@@ -242,8 +242,11 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
<a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank"> <a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank">
r/{decision.subreddit} — {decision.thingKind} by u/{decision.author} r/{decision.subreddit} — {decision.thingKind} by u/{decision.author}
</a> </a>
<span className={`pill ${decision.voteSubmitted ? 'success' : 'neutral'}`}> <span className={`pill ${decision.voteStatus === 'submitted' ? 'success' : decision.voteStatus === 'failed' ? 'danger' : 'neutral'}`}>
{decision.voteSubmitted ? 'Upvote submitted' : 'Simulated selection'} {decision.voteStatus === 'pending' ? 'Pending upvote'
: decision.voteStatus === 'submitted' ? 'Upvote submitted'
: decision.voteStatus === 'failed' ? 'Upvote failed'
: 'Simulated selection'}
</span> </span>
</div> </div>
<p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p> <p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p>
+1 -1
View File
@@ -64,7 +64,7 @@ Do not add local retries around this layer without preserving the shared schedul
After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing session. The durable `next_due_at` gate survives restarts; a deterministic per-account daily target and randomized waking-hours spacing avoid recalculating a flapping schedule on each minute tick. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects randomly. After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing session. The durable `next_due_at` gate survives restarts; a deterministic per-account daily target and randomized waking-hours spacing avoid recalculating a flapping schedule on each minute tick. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects randomly.
The terminal operation records the selection in SQLite before any remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends one authenticated Reddit `api/vote` request through the same browser transport and shared coordinator as growth comments, then marks the audit record submitted only after Reddit confirms it. Scan mode and browser dry-run always record simulation-only selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and daily-target gates while preserving content exclusions and follows the same automatic-upvote and dry-run rules. The terminal operation records a pending selection in SQLite before any remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends one authenticated Reddit `api/vote` request through the same browser transport and shared coordinator as growth comments, then marks the audit record `submitted` only after Reddit confirms it; safe failures become `failed`. Scan mode and browser dry-run record `simulated` selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and daily-target gates while preserving content exclusions and follows the same automatic-upvote and dry-run rules.
## Reply-first conversation automation ## Reply-first conversation automation
+2 -2
View File
@@ -21,7 +21,7 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown. - `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts. - `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
- `account_browsing_settings`: disabled-by-default per-account browsing policy, conservative daily range, post/comment weights, quiet hours, spacing, NSFW policy, default-off automatic-upvote setting, and durable next due time. - `account_browsing_settings`: disabled-by-default per-account browsing policy, conservative daily range, post/comment weights, quiet hours, spacing, NSFW policy, default-off automatic-upvote setting, and durable next due time.
- `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, submitted-vote flag, and scheduled-session audit. Reddit text is deliberately not retained. - `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, durable vote status (`simulated`, `pending`, `submitted`, or `failed`), and scheduled-session audit. Reddit text is deliberately not retained.
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID. The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
@@ -63,7 +63,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
11. Exactly one account may comment on a given thread. `account_growth_comments` enforces this with a unique index on `post_id` across all accounts, because two owned accounts commenting on the same post is the easiest pattern for Reddit to correlate. 11. Exactly one account may comment on a given thread. `account_growth_comments` enforces this with a unique index on `post_id` across all accounts, because two owned accounts commenting on the same post is the easiest pattern for Reddit to correlate.
12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn. 12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn.
13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally. 13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally.
14. A browsing selection is persisted before any vote request. `vote_submitted` becomes true only after Reddit confirms the authenticated upvote; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion. 14. A browsing selection is persisted as `pending` before any vote request. It becomes `submitted` only after Reddit confirms the authenticated upvote, or `failed` with the safe error reason; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion.
10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval. 10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval.
+1 -1
View File
@@ -70,7 +70,7 @@ This is inauthentic account activity under Reddit's content policy, and many sub
- Growth deliberately continues while an account is held out of outbound messaging, because building the history is what lifts that hold. It is always the lowest-priority work an account performs, after inbound replies. - Growth deliberately continues while an account is held out of outbound messaging, because building the history is what lifts that hold. It is always the lowest-priority work an account performs, after inbound replies.
- Dry run prepares and records a comment but never posts it. Every posted comment, and every comment refused by either gate, is kept with its reason in the growth audit trail. - Dry run prepares and records a comment but never posts it. Every posted comment, and every comment refused by either gate, is kept with its reason in the growth audit trail.
Browsing sessions are a separate, disabled-by-default account-growth experiment. They select a conservative number of eligible posts or comments from the same configured communities. The per-account automatic-upvote switch is separately disabled by default; when enabled, a selected item is upvoted through the account's authenticated browser session only in running mode and outside browser dry-run. Owned-account content, outreach recipients, cross-account duplicates, growth-comment threads, promotion experiments, moderator content, closed or removed threads, old or low-context items, and NSFW content unless explicitly allowed are excluded before randomness is applied. Every selection records whether Reddit confirmed the vote, and browsing never affects the adaptive comment-growth trust level. Browsing sessions are a separate, disabled-by-default account-growth experiment. They select a conservative number of eligible posts or comments from the same configured communities. The per-account automatic-upvote switch is separately disabled by default; when enabled, a selected item is upvoted through the account's authenticated browser session only in running mode and outside browser dry-run. Owned-account content, outreach recipients, cross-account duplicates, growth-comment threads, promotion experiments, moderator content, closed or removed threads, old or low-context items, and NSFW content unless explicitly allowed are excluded before randomness is applied. Every selection records whether the vote is simulated, pending, submitted, or failed, and browsing never affects the adaptive comment-growth trust level.
## Operator exchange media ## Operator exchange media
+1 -1
View File
@@ -42,7 +42,7 @@ Comment-based growth exists so a new account accumulates the age/karma history R
| `reddit/comment-client.ts` | Comment submission and comment-state lookups for one account | Classifies Reddit's error codes into retry/abandon/disable/re-auth/stop; runs inside the account's own browser session and shares the request coordinator. | | `reddit/comment-client.ts` | Comment submission and comment-state lookups for one account | Classifies Reddit's error codes into retry/abandon/disable/re-auth/stop; runs inside the account's own browser session and shares the request coordinator. |
| `database/growth-repository.ts` | Growth settings, per-account community list, durable comment records | One comment per thread across every account; mutations require the expected worker lease; queue clearing deletes only unposted discovered/retryable rows. | | `database/growth-repository.ts` | Growth settings, per-account community list, durable comment records | One comment per thread across every account; mutations require the expected worker lease; queue clearing deletes only unposted discovered/retryable rows. |
| `services/account-browsing-simulation.ts` | Durable pacing, eligibility, post/comment weighting, selection, and optional vote dispatch | Runs below comment growth; votes require the per-account opt-in plus running mode outside dry-run, and uncertain writes are never retried. | | `services/account-browsing-simulation.ts` | Durable pacing, eligibility, post/comment weighting, selection, and optional vote dispatch | Runs below comment growth; votes require the per-account opt-in plus running mode outside dry-run, and uncertain writes are never retried. |
| `database/browsing-simulation-repository.ts` | Per-account browsing policy, cross-account selection checks, and minimal decision audit | Stores identity/permalink, reasons, and whether Reddit confirmed a vote without copying Reddit content; browsing never feeds comment trust. | | `database/browsing-simulation-repository.ts` | Per-account browsing policy, cross-account selection checks, and minimal decision audit | Stores identity/permalink, safe reasons, and durable vote state without copying Reddit content; browsing never feeds comment trust. |
Verification is the safety mechanism that matters most. Posted comments are swept at roughly 15 minutes, 2 hours, and 24 hours using one batched `info.json` request, and a single signed-out probe on the second sweep decides whether the comment is publicly visible at all. Two consecutive removals, or one invisible comment, hard-pauses the account until an operator clears it; a failed probe never pauses anything. Verification is the safety mechanism that matters most. Posted comments are swept at roughly 15 minutes, 2 hours, and 24 hours using one batched `info.json` request, and a single signed-out probe on the second sweep decides whether the comment is publicly visible at all. Two consecutive removals, or one invisible comment, hard-pauses the account until an operator clears it; a failed probe never pauses anything.
+1
View File
@@ -48,6 +48,7 @@ export const BrowsingDecisionSchema = z.strictObject({
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), decision: z.enum(['simulated-upvote', 'skipped', 'rejected']),
reason: z.string(), reason: z.string(),
voteSubmitted: z.boolean(), voteSubmitted: z.boolean(),
voteStatus: z.enum(['simulated', 'pending', 'submitted', 'failed']),
decidedAt: z.number(), decidedAt: z.number(),
}); });
+15 -5
View File
@@ -3,6 +3,7 @@ import type { GrowthQuietHours } from './growth-repository.js';
export type BrowsingThingKind = 'post' | 'comment'; export type BrowsingThingKind = 'post' | 'comment';
export type BrowsingDecisionKind = 'simulated-upvote' | 'skipped' | 'rejected'; export type BrowsingDecisionKind = 'simulated-upvote' | 'skipped' | 'rejected';
export type BrowsingVoteStatus = 'simulated' | 'pending' | 'submitted' | 'failed';
export interface AccountBrowsingSettingsRecord { export interface AccountBrowsingSettingsRecord {
accountId: string; accountId: string;
@@ -47,6 +48,7 @@ export interface BrowsingDecisionRecord {
decidedAt: number; decidedAt: number;
scheduledSessionId: string; scheduledSessionId: string;
voteSubmitted: boolean; voteSubmitted: boolean;
voteStatus: BrowsingVoteStatus;
} }
export interface BrowsingDecisionCounts { export interface BrowsingDecisionCounts {
@@ -136,13 +138,13 @@ export class AccountBrowsingSimulationRepository {
return this.db.prepare(` return this.db.prepare(`
INSERT OR IGNORE INTO account_browsing_decisions ( INSERT OR IGNORE INTO account_browsing_decisions (
account_id, thing_fullname, thing_kind, post_id, subreddit, author, account_id, thing_fullname, thing_kind, post_id, subreddit, author,
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id, vote_submitted permalink, decision, reason, discovered_at, decided_at, scheduled_session_id, vote_submitted, vote_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
decision.accountId, decision.thingFullname, decision.thingKind, decision.postId, decision.accountId, decision.thingFullname, decision.thingKind, decision.postId,
decision.subreddit, decision.author, decision.permalink, decision.decision, decision.subreddit, decision.author, decision.permalink, decision.decision,
decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId, decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId,
decision.voteSubmitted ? 1 : 0, decision.voteSubmitted ? 1 : 0, decision.voteStatus,
).changes === 1; ).changes === 1;
} }
@@ -154,11 +156,18 @@ export class AccountBrowsingSimulationRepository {
markVoteSubmitted(accountId: string, thingFullname: string): boolean { markVoteSubmitted(accountId: string, thingFullname: string): boolean {
return this.db.prepare(` return this.db.prepare(`
UPDATE account_browsing_decisions SET vote_submitted = 1 UPDATE account_browsing_decisions SET vote_submitted = 1, vote_status = 'submitted', reason = 'Reddit upvote submitted'
WHERE account_id = ? AND thing_fullname = ? AND vote_submitted = 0 WHERE account_id = ? AND thing_fullname = ? AND vote_status = 'pending'
`).run(accountId, thingFullname).changes === 1; `).run(accountId, thingFullname).changes === 1;
} }
markVoteFailed(accountId: string, thingFullname: string, reason: string): boolean {
return this.db.prepare(`
UPDATE account_browsing_decisions SET vote_status = 'failed', reason = ?
WHERE account_id = ? AND thing_fullname = ? AND vote_status = 'pending'
`).run(reason, accountId, thingFullname).changes === 1;
}
hasAccountDecision(accountId: string, thingFullname: string): boolean { hasAccountDecision(accountId: string, thingFullname: string): boolean {
return this.db.prepare(` return this.db.prepare(`
SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ? SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ?
@@ -305,5 +314,6 @@ function mapDecision(row: Record<string, unknown>): BrowsingDecisionRecord {
decidedAt: row.decided_at as number, decidedAt: row.decided_at as number,
scheduledSessionId: row.scheduled_session_id as string, scheduledSessionId: row.scheduled_session_id as string,
voteSubmitted: row.vote_submitted === 1, voteSubmitted: row.vote_submitted === 1,
voteStatus: row.vote_status as BrowsingVoteStatus,
}; };
} }
+4
View File
@@ -612,6 +612,10 @@ export function runMigrations(db: Database.Database): void {
addColumnIfNotExists(db, 'account_browsing_settings', 'auto_vote_enabled', 'INTEGER NOT NULL DEFAULT 0 CHECK(auto_vote_enabled IN (0, 1))'); addColumnIfNotExists(db, 'account_browsing_settings', 'auto_vote_enabled', 'INTEGER NOT NULL DEFAULT 0 CHECK(auto_vote_enabled IN (0, 1))');
addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_submitted', 'INTEGER NOT NULL DEFAULT 0 CHECK(vote_submitted IN (0, 1))'); addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_submitted', 'INTEGER NOT NULL DEFAULT 0 CHECK(vote_submitted IN (0, 1))');
}, },
() => {
addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_status', "TEXT NOT NULL DEFAULT 'simulated' CHECK(vote_status IN ('simulated', 'pending', 'submitted', 'failed'))");
db.exec("UPDATE account_browsing_decisions SET vote_status = 'submitted' WHERE vote_submitted = 1");
},
]; ];
let currentVersion = db.pragma('user_version', { simple: true }) as number; let currentVersion = db.pragma('user_version', { simple: true }) as number;
+16 -4
View File
@@ -120,17 +120,27 @@ export class AccountBrowsingSimulationService {
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt }; if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
const selected = candidates[Math.floor(this.random() * candidates.length)]; const selected = candidates[Math.floor(this.random() * candidates.length)];
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible browsing selection', sessionId, now); const voter = settings.autoVoteEnabled && allowVoting && !this.options.dryRun
? this.options.voter
: undefined;
const decision = toDecision(
this.options.accountId, selected, 'simulated-upvote',
voter ? 'Upvote pending Reddit confirmation' : 'Eligible browsing selection',
sessionId, now, voter ? 'pending' : 'simulated',
);
await this.options.sink.recordSimulation(decision); await this.options.sink.recordSimulation(decision);
if (settings.autoVoteEnabled && allowVoting && !this.options.dryRun && this.options.voter) { if (voter) {
const result = await this.options.voter.upvote(selected.fullname); const result = await voter.upvote(selected.fullname);
if (result.status === 'submitted') { if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname); this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
decision.voteSubmitted = true; decision.voteSubmitted = true;
decision.voteStatus = 'submitted';
decision.reason = 'Reddit upvote submitted'; decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`); this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`);
} else { } else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`; decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
decision.voteStatus = 'failed';
this.options.repository.markVoteFailed(this.options.accountId, selected.fullname, decision.reason);
this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`); this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`);
} }
} }
@@ -165,7 +175,7 @@ export class AccountBrowsingSimulationService {
} }
if (!this.options.repository.hasAccountDecision(this.options.accountId, thing.fullname)) { if (!this.options.repository.hasAccountDecision(this.options.accountId, thing.fullname)) {
await this.options.sink.recordSimulation( await this.options.sink.recordSimulation(
toDecision(this.options.accountId, thing, 'rejected', reason, sessionId, now), toDecision(this.options.accountId, thing, 'rejected', reason, sessionId, now, 'simulated'),
); );
} }
} }
@@ -293,6 +303,7 @@ function toDecision(
reason: string, reason: string,
sessionId: string, sessionId: string,
now: number, now: number,
voteStatus: BrowsingDecisionRecord['voteStatus'],
): BrowsingDecisionRecord { ): BrowsingDecisionRecord {
return { return {
accountId, accountId,
@@ -308,5 +319,6 @@ function toDecision(
decidedAt: now, decidedAt: now,
scheduledSessionId: sessionId, scheduledSessionId: sessionId,
voteSubmitted: false, voteSubmitted: false,
voteStatus,
}; };
} }
+1 -1
View File
@@ -47,7 +47,7 @@ export const BrowsingStatsSchema = z.strictObject({
export const BrowsingDecisionSchema = z.strictObject({ export const BrowsingDecisionSchema = z.strictObject({
thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(), thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(),
subreddit: z.string(), author: z.string(), permalink: z.string(), subreddit: z.string(), author: z.string(), permalink: z.string(),
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), voteSubmitted: z.boolean(), decidedAt: z.number(), decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), voteSubmitted: z.boolean(), voteStatus: z.enum(['simulated', 'pending', 'submitted', 'failed']), decidedAt: z.number(),
}); });
export const BrowsingSummarySchema = z.strictObject({ export const BrowsingSummarySchema = z.strictObject({
+1
View File
@@ -334,6 +334,7 @@ function presentBrowsing(
decision: decision.decision, decision: decision.decision,
reason: decision.reason, reason: decision.reason,
voteSubmitted: decision.voteSubmitted, voteSubmitted: decision.voteSubmitted,
voteStatus: decision.voteStatus,
decidedAt: decision.decidedAt, decidedAt: decision.decidedAt,
})), })),
} : {}), } : {}),
+24 -9
View File
@@ -151,7 +151,7 @@ describe('AccountBrowsingSimulationService', () => {
const result = await fixture.instance.runNow(); const result = await fixture.instance.runNow();
expect(result).toMatchObject({ expect(result).toMatchObject({
kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: true }, kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: true, voteStatus: 'submitted' },
}); });
expect(fixture.getHotPosts).toHaveBeenCalledOnce(); expect(fixture.getHotPosts).toHaveBeenCalledOnce();
expect(voter.upvote).toHaveBeenCalledWith('t3_one'); expect(voter.upvote).toHaveBeenCalledWith('t3_one');
@@ -168,7 +168,7 @@ describe('AccountBrowsingSimulationService', () => {
}); });
await expect(fixture.instance.runNow()).resolves.toMatchObject({ await expect(fixture.instance.runNow()).resolves.toMatchObject({
kind: 'simulated', decision: { voteSubmitted: false }, kind: 'simulated', decision: { voteSubmitted: false, voteStatus: 'simulated' },
}); });
expect(voter.upvote).not.toHaveBeenCalled(); expect(voter.upvote).not.toHaveBeenCalled();
}); });
@@ -192,9 +192,9 @@ describe('AccountBrowsingSimulationService', () => {
const result = await service({ voter }).instance.tick({ allowVoting: true }); const result = await service({ voter }).instance.tick({ allowVoting: true });
expect(voter.upvote).toHaveBeenCalledWith('t3_one'); expect(voter.upvote).toHaveBeenCalledWith('t3_one');
expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: true, reason: 'Reddit upvote submitted' } }); expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: true, voteStatus: 'submitted', reason: 'Reddit upvote submitted' } });
expect(repository.listRecentSelections(ACCOUNT)).toEqual([ expect(repository.listRecentSelections(ACCOUNT)).toEqual([
expect.objectContaining({ thingFullname: 't3_one', voteSubmitted: true }), expect.objectContaining({ thingFullname: 't3_one', voteSubmitted: true, voteStatus: 'submitted' }),
]); ]);
}); });
@@ -206,7 +206,22 @@ describe('AccountBrowsingSimulationService', () => {
const result = await service({ voter }).instance.tick(); const result = await service({ voter }).instance.tick();
expect(voter.upvote).not.toHaveBeenCalled(); expect(voter.upvote).not.toHaveBeenCalled();
expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: false } }); expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: false, voteStatus: 'simulated' } });
});
it('persists an unsuccessful automatic vote as failed instead of mislabelling it simulated', async () => {
enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const voter = { upvote: vi.fn().mockResolvedValue({ status: 'rate-limited', reason: 'slow down' }) };
const result = await service({ voter }).instance.tick({ allowVoting: true });
expect(result).toMatchObject({
kind: 'simulated', decision: { voteSubmitted: false, voteStatus: 'failed', reason: 'Reddit upvote not submitted: slow down' },
});
expect(repository.listRecentSelections(ACCOUNT)).toEqual([
expect.objectContaining({ voteStatus: 'failed', reason: 'Reddit upvote not submitted: slow down' }),
]);
}); });
it('rejects owned authors, outreach recipients, NSFW and cross-account duplicates', async () => { it('rejects owned authors, outreach recipients, NSFW and cross-account duplicates', async () => {
@@ -214,7 +229,7 @@ describe('AccountBrowsingSimulationService', () => {
const otherSelection: BrowsingDecisionRecord = { const otherSelection: BrowsingDecisionRecord = {
accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken', accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken',
subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote', subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other', voteSubmitted: false, reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other', voteSubmitted: false, voteStatus: 'simulated',
}; };
repository.recordDecision(otherSelection); repository.recordDecision(otherSelection);
enable({ postWeight: 100, commentWeight: 0, allowNsfw: false }); enable({ postWeight: 100, commentWeight: 0, allowNsfw: false });
@@ -244,7 +259,7 @@ describe('AccountBrowsingSimulationService', () => {
const first: BrowsingDecisionRecord = { const first: BrowsingDecisionRecord = {
accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same', accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same',
subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote', subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first', voteSubmitted: false, reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first', voteSubmitted: false, voteStatus: 'simulated',
}; };
expect(repository.recordDecision(first)).toBe(true); expect(repository.recordDecision(first)).toBe(true);
expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' })) expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' }))
@@ -256,7 +271,7 @@ describe('AccountBrowsingSimulationService', () => {
repository.recordDecision({ repository.recordDecision({
accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done', accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done',
subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote', subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done', voteSubmitted: false, reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done', voteSubmitted: false, voteStatus: 'simulated',
}); });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const fixture = service(); const fixture = service();
@@ -290,7 +305,7 @@ describe('browsing migration', () => {
runMigrations(db); runMigrations(db);
const version = db.pragma('user_version', { simple: true }) as number; const version = db.pragma('user_version', { simple: true }) as number;
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;'); db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
db.pragma(`user_version = ${version - 2}`); db.pragma(`user_version = ${version - 3}`);
runMigrations(db); runMigrations(db);
insertAccount(db, ACCOUNT, 'owner'); insertAccount(db, ACCOUNT, 'owner');
+1 -1
View File
@@ -12,7 +12,7 @@ const OTHER_ACCOUNT = '@t2_other:reddit.com';
/** Migrations that make up account growth, replayed together in the upgrade test. */ /** Migrations that make up account growth, replayed together in the upgrade test. */
// Browsing migrations depend on the growth tables, so a pre-growth fixture // Browsing migrations depend on the growth tables, so a pre-growth fixture
// must rewind past both the growth sequence and the later browsing additions. // must rewind past both the growth sequence and the later browsing additions.
const GROWTH_MIGRATION_COUNT = 5; const GROWTH_MIGRATION_COUNT = 6;
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost { function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return { return {