Skip to content

Commit

Permalink
Merge pull request #33 from PotLock/feat/test-branch
Browse files Browse the repository at this point in the history
Adds test route and mock twitter submission service
  • Loading branch information
elliotBraem authored Feb 1, 2025
2 parents b51fc59 + b860e35 commit 92a9986
Show file tree
Hide file tree
Showing 13 changed files with 558 additions and 59 deletions.
166 changes: 145 additions & 21 deletions backend/src/__tests__/mocks/twitter-service.mock.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,100 @@
import { Tweet } from "agent-twitter-client";
import { SearchMode, Tweet } from "agent-twitter-client";
import { TwitterService } from "../../services/twitter/client";
import { logger } from "../../utils/logger";

export class MockTwitterService {
export class MockTwitterService extends TwitterService {
private mockTweets: Tweet[] = [];
private mockUserIds: Map<string, string> = new Map();
private lastCheckedTweetId: string | null = null;
private tweetIdCounter: bigint = BigInt(Date.now());
private testLastCheckedTweetId: string | null = null;

public addMockTweet(tweet: Tweet) {
this.mockTweets.push(tweet);
constructor() {
// Pass config with the bot's username so mentions are found
super({
username: "test_bot",
password: "mock_pass",
email: "[email protected]",
});
// Override the client with a basic mock
(this as any).client = {
isLoggedIn: async () => true,
login: async () => {},
logout: async () => {},
getCookies: async () => [],
setCookies: async () => {},
fetchSearchTweets: async (
query: string,
count: number,
mode: SearchMode,
) => {
// Filter tweets that match the query (mentions @test_bot)
const matchingTweets = this.mockTweets.filter((tweet) =>
tweet.text?.includes("@test_bot"),
);

// Sort by ID descending (newest first) to match Twitter search behavior
const sortedTweets = [...matchingTweets].sort((a, b) => {
const aId = BigInt(a.id);
const bId = BigInt(b.id);
return bId > aId ? 1 : bId < aId ? -1 : 0;
});

return {
tweets: sortedTweets.slice(0, count),
};
},
likeTweet: async (tweetId: string) => {
logger.info(`Mock: Liked tweet ${tweetId}`);
return true;
},
sendTweet: async (message: string, replyToId?: string) => {
const newTweet = this.addMockTweet({
text: message,
username: "test_bot",
inReplyToStatusId: replyToId,
});
return {
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: newTweet.id,
},
},
},
},
}),
} as Response;
},
};
}

private getNextTweetId(): string {
this.tweetIdCounter = this.tweetIdCounter + BigInt(1);
return this.tweetIdCounter.toString();
}

public addMockTweet(tweet: Partial<Tweet> & { inReplyToStatusId?: string }) {
const fullTweet: Tweet = {
id: tweet.id || this.getNextTweetId(),
text: tweet.text || "",
username: tweet.username || "test_user",
userId: tweet.userId || `mock-user-id-${tweet.username || "test_user"}`,
timeParsed: tweet.timeParsed || new Date(),
hashtags: tweet.hashtags || [],
mentions: tweet.mentions || [],
photos: tweet.photos || [],
urls: tweet.urls || [],
videos: tweet.videos || [],
thread: [],
inReplyToStatusId: tweet.inReplyToStatusId,
};
this.mockTweets.push(fullTweet);
logger.info(
`Mock: Added tweet "${fullTweet.text}" from @${fullTweet.username}${tweet.inReplyToStatusId ? ` as reply to ${tweet.inReplyToStatusId}` : ""}`,
);
return fullTweet;
}

public addMockUserId(username: string, userId: string) {
Expand All @@ -15,41 +103,77 @@ export class MockTwitterService {

public clearMockTweets() {
this.mockTweets = [];
logger.info("Mock: Cleared all tweets");
}

async initialize(): Promise<void> {
// No-op for mock
logger.info("Mock Twitter service initialized");
}

async stop(): Promise<void> {
// No-op for mock
logger.info("Mock Twitter service stopped");
}

async getUserIdByScreenName(screenName: string): Promise<string> {
return this.mockUserIds.get(screenName) || `mock-user-id-${screenName}`;
}

async fetchAllNewMentions(): Promise<Tweet[]> {
return this.mockTweets;
}
const BATCH_SIZE = 200;

async getTweet(tweetId: string): Promise<Tweet | null> {
return this.mockTweets.find((t) => t.id === tweetId) || null;
}
// Get the last tweet ID we processed
const lastCheckedId = this.testLastCheckedTweetId
? BigInt(this.testLastCheckedTweetId)
: null;

async replyToTweet(tweetId: string, message: string): Promise<string | null> {
return `mock-reply-${Date.now()}`;
}
// Get latest tweets first (up to batch size), excluding already checked tweets
const latestTweets = [...this.mockTweets]
.sort((a, b) => {
const aId = BigInt(a.id);
const bId = BigInt(b.id);
return bId > aId ? -1 : bId < aId ? 1 : 0; // Descending order (newest first)
})
.filter((tweet) => {
const tweetId = BigInt(tweet.id);
return !lastCheckedId || tweetId > lastCheckedId;
})
.slice(0, BATCH_SIZE);

if (latestTweets.length === 0) {
logger.info("No tweets found");
return [];
}

// Filter for mentions
const newMentions = latestTweets.filter(
(tweet) =>
tweet.text?.includes("@test_bot") ||
tweet.mentions?.some((m) => m.username === "test_bot"),
);

async setLastCheckedTweetId(tweetId: string): Promise<void> {
this.lastCheckedTweetId = tweetId;
// Sort chronologically (oldest to newest) to match real service
newMentions.sort((a, b) => {
const aId = BigInt(a.id);
const bId = BigInt(b.id);
return aId > bId ? 1 : aId < bId ? -1 : 0;
});

// Update last checked ID if we found new tweets
if (latestTweets.length > 0) {
// Use the first tweet from latestTweets since it's the newest (they're in descending order)
const highestId = latestTweets[0].id;
await this.setLastCheckedTweetId(highestId);
}

return newMentions;
}

async likeTweet(tweetId: string): Promise<void> {
return;
async setLastCheckedTweetId(tweetId: string) {
this.testLastCheckedTweetId = tweetId;
logger.info(`Last checked tweet ID updated to: ${tweetId}`);
}

getLastCheckedTweetId(): string | null {
return this.lastCheckedTweetId;
async getTweet(tweetId: string): Promise<Tweet | null> {
return this.mockTweets.find((t) => t.id === tweetId) || null;
}
}
52 changes: 22 additions & 30 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { db } from "./services/db";
import { DistributionService } from "./services/distribution/distribution.service";
import { SubmissionService } from "./services/submissions/submission.service";
import { TwitterService } from "./services/twitter/client";
import { testRoutes, mockTwitterService } from "./routes/test";
import {
cleanup,
failSpinner,
Expand Down Expand Up @@ -50,19 +51,25 @@ export async function main() {
await distributionService.initialize(config.plugins);
succeedSpinner("distribution-init", "distribution service initialized");

// Try to initialize Twitter service, but continue if it fails
// Use mock service in development, real service in production
try {
startSpinner("twitter-init", "Initializing Twitter service...");
twitterService = new TwitterService({
username: process.env.TWITTER_USERNAME!,
password: process.env.TWITTER_PASSWORD!,
email: process.env.TWITTER_EMAIL!,
twoFactorSecret: process.env.TWITTER_2FA_SECRET,
});
await twitterService.initialize();
if (process.env.NODE_ENV === "development") {
logger.info("Using mock Twitter service");
twitterService = mockTwitterService;
await twitterService.initialize();
} else {
twitterService = new TwitterService({
username: process.env.TWITTER_USERNAME!,
password: process.env.TWITTER_PASSWORD!,
email: process.env.TWITTER_EMAIL!,
twoFactorSecret: process.env.TWITTER_2FA_SECRET,
});
await twitterService.initialize();
}
succeedSpinner("twitter-init", "Twitter service initialized");

// Only initialize submission service if Twitter is available
// Initialize submission service
startSpinner("submission-init", "Initializing submission service...");
submissionService = new SubmissionService(
twitterService,
Expand All @@ -86,12 +93,12 @@ export async function main() {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"], // Allow images from HTTPS sources
fontSrc: ["'self'", "data:", "https:"], // Allow fonts
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:", "https:"],
},
},
crossOriginEmbedderPolicy: false, // Required for some static assets
crossOriginResourcePolicy: { policy: "cross-origin" }, // Allow resources to be shared
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
xFrameOptions: { action: "sameorigin" },
}),
)
Expand All @@ -102,6 +109,8 @@ export async function main() {
}),
)
.use(swagger())
// Include test routes in development
.use(process.env.NODE_ENV === "development" ? testRoutes : new Elysia())
.get("/health", () => new Response("OK", { status: 200 }))
// API Routes
.get("/api/twitter/last-tweet-id", () => {
Expand Down Expand Up @@ -180,23 +189,6 @@ export async function main() {
const rawConfig = await configService.getRawConfig();
return rawConfig.feeds;
})
// .post("/api/twitter/cookies", async ({ body }: { body: TwitterCookie[] }) => {
// if (!twitterService) {
// throw new Error("Twitter service not available");
// }
// if (!Array.isArray(body)) {
// throw new Error("Expected array of cookies");
// }
// await twitterService.setCookies(body);
// return { success: true };
// })
// .get("/api/twitter/cookies", () => {
// if (!twitterService) {
// throw new Error("Twitter service not available");
// }
// const cookies = twitterService.getCookies();
// return cookies || [];
// })
.get(
"/api/config/:feedId",
({ params: { feedId } }: { params: { feedId: string } }) => {
Expand Down
59 changes: 59 additions & 0 deletions backend/src/routes/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Tweet } from "agent-twitter-client";
import { Elysia } from "elysia";
import { MockTwitterService } from "../__tests__/mocks/twitter-service.mock";

// Create a single mock instance to maintain state
const mockTwitterService = new MockTwitterService();

// Helper to create a tweet object
const createTweet = (
id: string,
text: string,
username: string,
inReplyToStatusId?: string,
hashtags?: string[],
): Tweet => {
return {
id,
text,
username,
userId: `mock-user-id-${username}`,
timeParsed: new Date(),
hashtags: hashtags ?? [],
mentions: [],
photos: [],
urls: [],
videos: [],
thread: [],
inReplyToStatusId,
};
};

export const testRoutes = new Elysia({ prefix: "/api/test" })
.guard({
beforeHandle: ({ request }) => {
// Only allow in development and test environments
if (process.env.NODE_ENV === "production") {
return new Response("Not found", { status: 404 });
}
},
})
.post("/tweets", async ({ body }) => {
const { id, text, username, inReplyToStatusId, hashtags } = body as {
id: string;
text: string;
username: string;
inReplyToStatusId?: string;
hashtags?: string[];
};
const tweet = createTweet(id, text, username, inReplyToStatusId, hashtags);
mockTwitterService.addMockTweet(tweet);
return tweet;
})
.post("/reset", () => {
mockTwitterService.clearMockTweets();
return { success: true };
});

// Export for use in tests and for replacing the real service
export { mockTwitterService };
17 changes: 15 additions & 2 deletions backend/src/services/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,28 @@ import fs from "fs/promises";
import path from "path";
import { AppConfig } from "../../types/config";
import { hydrateConfigValues } from "../../utils/config";
import { logger } from "../../utils/logger";

export class ConfigService {
private static instance: ConfigService;
private config: AppConfig | null = null;
private configPath: string;

private constructor() {
// Always look for config relative to the distribution directory
this.configPath = path.resolve(__dirname, "../../../../curate.config.json");
// Use test config in development mode
if (process.env.NODE_ENV === "development") {
this.configPath = path.resolve(
__dirname,
"../../../../curate.config.test.json",
);
logger.info("Using test configuration");
} else {
this.configPath = path.resolve(
__dirname,
"../../../../curate.config.json",
);
logger.info("Using production configuration");
}
}

public static getInstance(): ConfigService {
Expand Down
6 changes: 3 additions & 3 deletions backend/src/services/twitter/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ export class TwitterService {
if (await this.client.isLoggedIn()) {
// Cache the new cookies
const cookies = await this.client.getCookies();
const formattedCookies = cookies.map((cookie) => ({
const formattedCookies: TwitterCookie[] = cookies.map((cookie) => ({
name: cookie.key,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
domain: cookie.domain || ".twitter.com", // Provide default if null
path: cookie.path || "/", // Provide default if null
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite as "Strict" | "Lax" | "None" | undefined,
Expand Down
Binary file modified bun.lockb
Binary file not shown.
Loading

0 comments on commit 92a9986

Please sign in to comment.