Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add tests #2

Merged
merged 20 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: test suite
on: [push]
jobs:

lint:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup node v20
uses: actions/setup-node@v4
with:
node-version: 20.18.0 # match .tool-versions file
- name: install
run: npm ci
- name: tests
run: npm run lint

test:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup node v20
uses: actions/setup-node@v4
with:
node-version: 20.18.0 # match .tool-versions file
- name: install
run: npm ci
- name: tests
run: npm run test
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 20.18.0
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]

* ...


## [v1.0.1]

* test suite (addresses [issue #1](https://github.com/kglw-dot-net/bot-bluesky-live/issues/1))
* this CHANGELOG file 🤘


## [v1.0.0]

### Added

* basic functionality


[Unreleased]: https://github.com/kglw-dot-net/bot-bluesky-live/compare/v1.0.1...HEAD
[v1.0.1]: https://github.com/kglw-dot-net/bot-bluesky-live/releases/tag/v1.0.1
[v1.0.0]: https://github.com/kglw-dot-net/bot-bluesky-live/releases/tag/v1.0.0
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ Brought to you by the [KGLW.net] Tech & Live-Coverage Teams!

## Docs

* [NPM package: `@proto/api`](https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md)
* Bluesky agent API: [`@proto/api` NPM package](https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md)
* test suite: [Vitest](https://vitest.dev/api/vi.html)


## Ops
Expand Down
13 changes: 13 additions & 0 deletions bluesky.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as process from 'process'
import {BskyAgent} from '@atproto/api'

export async function login():Promise<BskyAgent> {
const agent = new BskyAgent({
service: 'https://bsky.social',
})
await agent.login({
identifier: process.env.BLUESKY_USERNAME!,
password: process.env.BLUESKY_PASSWORD!,
})
return agent
}
208 changes: 208 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import createFetchMock from 'vitest-fetch-mock'
import {BskyAgent} from '@atproto/api'
import {login} from './bluesky.js'
import {
handlePayload,
isSongfishPayload,
type LambdaEvent,
type SongfishWebhookPayload,
} from './index.js'
import testFixture from './test-fixture.json'

vi.mock('./bluesky.js') // use e.g.: vi.mocked(login).mockResolvedValue({})

const fetchMocker = createFetchMock(vi)
fetchMocker.enableMocks()

function mockJson(urlMatcher, jsonPayload) {
fetchMocker.mockIf(urlMatcher, () => ({
body: JSON.stringify(jsonPayload),
contentType: 'application/json',
}))
}

describe('isSongfishPayload', () => {
test('is a function', () => {
expect(typeof isSongfishPayload).toBe('function')
})
describe('with non-object payload', () => {
test('is false', () => {
expect(isSongfishPayload(undefined)).toBe(false)
expect(isSongfishPayload(9)).toBe(false)
expect(isSongfishPayload('foo')).toBe(false)
alxndr marked this conversation as resolved.
Show resolved Hide resolved
})
})
describe('with object payload', () => {
describe('when object does not have `body`', () => {
test('is false', () => {
expect(isSongfishPayload({foo:'bar'})).toBe(false)
})
})
describe('when object does have `body`', () => {
describe('but it is not stringified JSON', () => {
test('is false', () => {
expect(isSongfishPayload({body:[]})).toBe(false)
})
})
describe('set to stringified JSON', () => {
describe('when stringified JSON is _not_ an object with a `show_id` key', () => {
test('is false', () => {
expect(isSongfishPayload({body:'[1, 2, 3]'})).toBe(false)
expect(isSongfishPayload({body:'{}'})).toBe(false)
})
})
describe('when stringified JSON _is_ an object with a `show_id` key', () => {
test('is true', () => {
expect(isSongfishPayload({body:'{"show_id": 999}'})).toBe(true)
})
})
})
})
})
})

describe('handlePayload', () => {
test('is a function', () => {
expect(typeof handlePayload).toBe('function')
})
describe('with malformed payload', () => {
test('throws with the error message from parsing the payload', async () => {
// @ts-expect-error test passing invalid string argument
await expect(() => handlePayload([])).rejects.toThrow('not valid JSON')
})
})
describe('with valid payload', () => {
let payload
beforeEach(() => {
const data = {
show_id: 123
}
payload = {
body: JSON.stringify(data)
}
})
describe('with invalid login', () => {
beforeEach(() => {
vi.mocked(login).mockRejectedValue('mocked login failure')
})
test('throws with the error message from logging in', async () => {
await expect(() => handlePayload(payload)).rejects.toThrow('mocked login failure')
})
})
describe('with valid login and prior post does not match latest song title', () => {
const mockedLoginReturnValue = {
getAuthorFeed: vi.fn().mockReturnValueOnce({data: {feed: [{post: {record: {text: 'Prior Post'}}}] }}),
}
beforeEach(() => {
vi.mocked(login).mockResolvedValue(mockedLoginReturnValue as unknown as BskyAgent)
})
afterEach(() => {
vi.mocked(login).mockReset()
})
describe(`with malformed Latest.json`, () => {
beforeEach(() => {
fetchMocker.mockIf(/\bkglw\.net\b.+\blatest\.json$/, () => 'this mocked Songfish response is malformed JSON')
})
test('returns a helpful message', async () => {
await expect(handlePayload(payload)).rejects.toThrow('not valid JSON')
})
})
describe(`when payload's show_id does _not_ match fetched JSON's data[-1].show_id`, () => {
let mockedPost
beforeEach(() => {
mockedPost = vi.fn()
vi.mocked(login).mockResolvedValue({
...mockedLoginReturnValue,
post: mockedPost,
} as unknown as BskyAgent)
mockJson(/\bkglw\.net\b.+\blatest\.json$/, {data: [
{show_id: 666, songname: 'Most Recent Song Name'},
]})
})
test('returns a helpful message', async () => {
await expect(handlePayload(payload)).resolves.toBe(
'payload show_id does not match latest show'
)
expect(mockedPost).not.toHaveBeenCalled()
})
})
describe(`when payload's show_id matches fetched JSON's data[-1].show_id`, () => {
let mockedPost
beforeEach(() => {
mockedPost = vi.fn().mockReturnValueOnce({mocked: true})
vi.mocked(login).mockResolvedValue({
...mockedLoginReturnValue,
post: mockedPost,
} as unknown as BskyAgent)
mockJson(/\bkglw\.net\b.+\blatest\.json$/, {data: [
{show_id: 789, songname: 'A Different Show and Song'},
{show_id: 456, songname: 'Yet Another Different Show and Song'},
{show_id: 123, songname: 'Most Recent Song Name'},
]})
})
test('posts the song title', async () => {
await handlePayload(payload)
expect(mockedPost).toHaveBeenCalledWith({text: 'Most Recent Song Name'})
})
})
})
describe('with valid login and prior post _does_ match latest song title', () => {
const mockedLoginReturnValue = {
getAuthorFeed: vi.fn().mockReturnValueOnce({data: {feed: [{post: {record: {text: 'Song Title'}}}] }}),
}
beforeEach(() => {
vi.mocked(login).mockResolvedValue(mockedLoginReturnValue as unknown as BskyAgent)
})
afterEach(() => {
vi.mocked(login).mockReset()
})
describe(`when payload's show_id matches fetched JSON's data[-1].show_id`, () => {
let mockedPost
beforeEach(() => {
mockedPost = vi.fn().mockReturnValueOnce({mocked: true})
vi.mocked(login).mockResolvedValue({
...mockedLoginReturnValue,
post: mockedPost,
} as unknown as BskyAgent)
mockJson(/\bkglw\.net\b.+\blatest\.json$/, {data: [
{show_id: 123, songname: 'Song Title'},
]})
})
test('does _not_ post the song title', async () => {
await handlePayload(payload)
expect(mockedPost).not.toHaveBeenCalled()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love that we do this to avoid double posting.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we accidentally add a new song and then remove it (maybe it turns out to just be a tease or jam), if we'd be able to retroactively delete the associated post. This isn't really relevant to this PR though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we accidentally add a new song and then remove it (maybe it turns out to just be a tease or jam), if we'd be able to retroactively delete the associated post. This isn't really relevant to this PR though.

hmmm yeah that's a bit tricky at this point... would be a sweet add-on though

})
})
})
})
describe('with fixture payload matching latest show_id', () => {
const testWithFixture = test.extend({
event: async ({}, use) => {
await use(testFixture.event)
}
})
const mockedLoginReturnValue = {
getAuthorFeed: vi.fn().mockReturnValueOnce({data: {feed: [{post: {record: {text: 'Prior Post'}}}] }}),
}
let mockedPost
beforeEach(() => {
mockedPost = vi.fn().mockReturnValueOnce({mocked: true})
vi.mocked(login).mockResolvedValue({
...mockedLoginReturnValue,
post: mockedPost,
} as unknown as BskyAgent)
mockJson(/\bkglw\.net\b.+\blatest\.json$/, {data: [
// the id 1699404057 is defined in the fixture file
{show_id: 1699404057, songname: 'Name of Song From Show #1699404057'},
]})
})
afterEach(() => {
vi.mocked(login).mockReset()
})
testWithFixture('does not throw', async ({event}:{event:LambdaEvent<SongfishWebhookPayload>}) => {
await expect(handlePayload(event)).resolves.not.toThrow()
expect(mockedPost).toHaveBeenCalledWith({text: 'Name of Song From Show #1699404057'})
})
})
})
40 changes: 16 additions & 24 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
import {BskyAgent} from '@atproto/api'
import * as dotenv from 'dotenv'
import * as process from 'process'
import {login} from './bluesky.js'

dotenv.config() // read env var declarations from a `.env` file

type SongfishWebhookPayload = {
body:{show_id:number}
export type LambdaEvent<T> = {
body: string // ... which is JSON that parses into a type T
}
export type SongfishWebhookPayload = {
show_id: number
}

async function loginBluesky():Promise<BskyAgent> {
const agent = new BskyAgent({
service: 'https://bsky.social',
})
await agent.login({
identifier: process.env.BLUESKY_USERNAME!,
password: process.env.BLUESKY_PASSWORD!,
})
return agent
export type BlueskyAgent = {
getAuthorFeed: Function
post: Function
}

function isSongfishPayload(event:any):event is SongfishWebhookPayload {
// note that testing this via commandline will mean that the event is a string payload, whereas on Lambda it is a true object
export function isSongfishPayload(event:any):event is LambdaEvent<SongfishWebhookPayload> {
return typeof event?.body === 'string' && event.body.includes('"show_id"')
// TODO could further verify that the song_id appears to be an int...
}

async function handlePayload(event:SongfishWebhookPayload):Promise<string> {
let payloadBody
export async function handlePayload(event:LambdaEvent<SongfishWebhookPayload>):Promise<string> {
let payloadBody:SongfishWebhookPayload
try {
payloadBody = JSON.parse(event.body)
} catch (err) {
console.log('error parsing event body', err)
throw err
}
let bsky
let bsky:BlueskyAgent
try {
bsky = await loginBluesky()
console.log('logged in successfully!')
bsky = await login()
} catch (err) {
console.log('login error', err)
throw err
Expand All @@ -48,14 +44,13 @@ async function handlePayload(event:SongfishWebhookPayload):Promise<string> {
throw err
}
const lastSongInLatestShow = latestData.slice(-1)[0]
console.log(JSON.stringify({payloadBody, lastSongInLatestShow}))
if (payloadBody.show_id !== lastSongInLatestShow.show_id) {
console.log(`payload show_id ${payloadBody.show_id} does not match latest show ${lastSongInLatestShow.show_id}`)
return 'payload show_id does not match latest show'
}
let lastPost
try {
const feed = await bsky.getAuthorFeed({actor: process.env.BLUESKY_USERNAME})
const feed = await bsky.getAuthorFeed({actor: process.env.BLUESKY_USERNAME}) // TODO extract this into bluesky file as well
lastPost = feed.data.feed[0]
} catch (err) {
console.log('error fetching most recent post...', err)
Expand All @@ -66,20 +61,17 @@ async function handlePayload(event:SongfishWebhookPayload):Promise<string> {
return 'most recent post is already about this song...'
}
const text = lastSongInLatestShow.songname
console.log('tryna post...', text)
const postResponse = await bsky.post({text})
console.log('Just posted!', postResponse)
return postResponse
}

export const handler = async (event:SongfishWebhookPayload|unknown):Promise<{statusCode:number,body:string}> => {
console.log('handler!!', event)
if (!isSongfishPayload(event))
return {
statusCode: 400,
body: `unexpected payload... typeof event: ${typeof event}`,
}
console.log('tryna handle it...')
try {
return {
statusCode: 200,
Expand Down
Loading