Files
household-bot/packages/application/src/miniapp-admin-service.test.ts

687 lines
19 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import type { HouseholdConfigurationRepository } from '@household/ports'
import { createMiniAppAdminService } from './miniapp-admin-service'
function repository(): HouseholdConfigurationRepository {
let memberAbsencePolicies: {
householdId: string
memberId: string
effectiveFromPeriod: string
policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive'
}[] = []
return {
registerTelegramHouseholdChat: async () => ({
status: 'existing',
household: {
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100123',
telegramChatType: 'supergroup',
title: 'Kojori House',
defaultLocale: 'ru'
}
}),
getTelegramHouseholdChat: async () => null,
getHouseholdChatByHouseholdId: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100123',
telegramChatType: 'supergroup',
title: 'Kojori House',
defaultLocale: 'ru'
}),
bindHouseholdTopic: async (input) => ({
householdId: input.householdId,
role: input.role,
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null
}),
getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [
{
householdId: 'household-1',
role: 'purchase',
telegramThreadId: '2',
topicName: 'Общие покупки'
}
],
clearHouseholdTopicBindings: async () => {},
listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token',
createdByTelegramUserId: null
}),
getHouseholdJoinToken: async () => null,
getHouseholdByJoinToken: async () => null,
upsertPendingHouseholdMember: async (input) => ({
householdId: input.householdId,
householdName: 'Kojori House',
telegramUserId: input.telegramUserId,
displayName: input.displayName,
username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null,
householdDefaultLocale: 'ru'
}),
getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null,
ensureHouseholdMember: async (input) => ({
id: `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
status: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
],
listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: async () => [
{
householdId: 'household-1',
householdName: 'Kojori House',
telegramUserId: '123456',
displayName: 'Stan',
username: 'stan',
languageCode: 'ru',
householdDefaultLocale: 'ru'
}
],
approvePendingHouseholdMember: async (input) =>
input.telegramUserId === '123456'
? {
id: 'member-123456',
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
rejectPendingHouseholdMember: async (input) => input.telegramUserId === '123456',
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100123',
telegramChatType: 'supergroup',
title: 'Kojori House',
defaultLocale: locale
}),
updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) =>
telegramUserId === '123456'
? {
id: 'member-123456',
householdId: 'household-1',
telegramUserId,
displayName: 'Stan',
status: 'active',
preferredLocale: locale,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
updateHouseholdMemberDisplayName: async (_householdId, memberId, displayName) =>
memberId === 'member-123456'
? {
id: memberId,
householdId: 'household-1',
telegramUserId: '123456',
displayName,
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
getHouseholdBillingSettings: async (householdId) => ({
householdId,
settlementCurrency: 'GEL',
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
settlementCurrency: 'GEL',
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi',
rentPaymentDestinations: input.rentPaymentDestinations ?? null
}),
getHouseholdAssistantConfig: async (householdId) => ({
householdId,
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
}),
updateHouseholdAssistantConfig: async (input) => ({
householdId: input.householdId,
assistantContext: input.assistantContext ?? 'House in Kojori',
assistantTone: input.assistantTone ?? 'Playful'
}),
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({
id: input.slug ?? 'utility-category-1',
householdId: input.householdId,
slug: input.slug ?? 'custom',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}),
promoteHouseholdAdmin: async (householdId, memberId) =>
memberId === 'member-123456'
? {
id: memberId,
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
: null,
demoteHouseholdAdmin: async (householdId, memberId) =>
memberId === 'member-123456'
? {
id: memberId,
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) =>
memberId === 'member-123456'
? {
id: memberId,
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight,
isAdmin: false
}
: null,
updateHouseholdMemberStatus: async (_householdId, memberId, status) =>
memberId === 'member-123456'
? {
id: memberId,
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status,
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
listHouseholdMemberAbsencePolicies: async () => memberAbsencePolicies,
upsertHouseholdMemberAbsencePolicy: async (input) => {
const next = {
householdId: input.householdId,
memberId: input.memberId,
effectiveFromPeriod: input.effectiveFromPeriod,
policy: input.policy
}
memberAbsencePolicies = [
...memberAbsencePolicies.filter(
(entry) =>
!(
entry.householdId === input.householdId &&
entry.memberId === input.memberId &&
entry.effectiveFromPeriod === input.effectiveFromPeriod
)
),
next
]
return next
}
}
}
describe('createMiniAppAdminService', () => {
test('returns billing settings, topic bindings, utility categories, and members for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.getSettings({
householdId: 'household-1',
actorIsAdmin: true
})
expect(result).toEqual({
status: 'ok',
householdName: 'Kojori House',
settings: {
householdId: 'household-1',
settlementCurrency: 'GEL',
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
},
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
},
topics: [
{
householdId: 'household-1',
role: 'purchase',
telegramThreadId: '2',
topicName: 'Общие покупки'
}
],
categories: [],
members: [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
],
memberAbsencePolicies: []
})
})
test('updates billing settings for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateSettings({
householdId: 'household-1',
actorIsAdmin: true,
rentAmountMajor: '700',
rentCurrency: 'USD',
rentDueDay: 21,
rentWarningDay: 18,
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi'
})
expect(result).toEqual({
status: 'ok',
householdName: 'Kojori House',
settings: {
householdId: 'household-1',
settlementCurrency: 'GEL',
rentAmountMinor: 70000n,
rentCurrency: 'USD',
rentDueDay: 21,
rentWarningDay: 18,
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: null
},
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
}
})
})
test('rejects invalid timezones when updating billing settings', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateSettings({
householdId: 'household-1',
actorIsAdmin: true,
rentDueDay: 21,
rentWarningDay: 18,
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Moon/Base'
})
expect(result).toEqual({
status: 'rejected',
reason: 'invalid_settings'
})
})
test('stores an away absence policy from the current local period', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateMemberAbsencePolicy({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456',
policy: 'away_rent_only'
})
expect(result).toEqual({
status: 'ok',
policy: {
householdId: 'household-1',
memberId: 'member-123456',
effectiveFromPeriod: '2026-03',
policy: 'away_rent_only'
}
})
})
test('upserts utility categories for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.upsertUtilityCategory({
householdId: 'household-1',
actorIsAdmin: true,
name: 'Internet',
sortOrder: 0,
isActive: true
})
expect(result).toEqual({
status: 'ok',
category: {
id: 'utility-category-1',
householdId: 'household-1',
slug: 'custom',
name: 'Internet',
sortOrder: 0,
isActive: true
}
})
})
test('lists pending members for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.listPendingMembers({
householdId: 'household-1',
actorIsAdmin: true
})
expect(result).toEqual({
status: 'ok',
members: [
{
householdId: 'household-1',
householdName: 'Kojori House',
telegramUserId: '123456',
displayName: 'Stan',
username: 'stan',
languageCode: 'ru',
householdDefaultLocale: 'ru'
}
]
})
})
test('rejects pending member listing for non-admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.listPendingMembers({
householdId: 'household-1',
actorIsAdmin: false
})
expect(result).toEqual({
status: 'rejected',
reason: 'not_admin'
})
})
test('approves a pending member for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.approvePendingMember({
householdId: 'household-1',
actorIsAdmin: true,
pendingTelegramUserId: '123456'
})
expect(result).toEqual({
status: 'approved',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
test('promotes an active member to household admin', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.promoteMemberToAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
})
})
test('demotes a household admin when another admin still exists', async () => {
const service = createMiniAppAdminService({
...repository(),
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'member-999999',
householdId: 'household-1',
telegramUserId: '999999',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
})
const result = await service.demoteMemberFromAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
test('rejects demoting the last household admin', async () => {
const service = createMiniAppAdminService({
...repository(),
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
})
const result = await service.demoteMemberFromAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'rejected',
reason: 'last_admin'
})
})
test('updates the acting member display name', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateOwnDisplayName({
householdId: 'household-1',
actorMemberId: 'member-123456',
displayName: 'Stan Cozy'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan Cozy',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
test('updates another member display name for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateMemberDisplayName({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456',
displayName: 'Stan Cozy'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan Cozy',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
test('updates a household member lifecycle status for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateMemberStatus({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456',
status: 'away'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'away',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
})