2026-04-03 00:56:56 +00:00
import { beforeEach , describe , expect } from "bun:test"
import { Effect , Exit , Layer , Option } from "effect"
2026-06-29 03:48:18 +00:00
import { HttpClient , HttpClientRequest , HttpClientResponse } from "effect/unstable/http"
2026-06-11 02:19:06 +00:00
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2026-06-27 16:29:21 +00:00
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
2026-06-11 02:19:06 +00:00
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionProjector } from "@opencode-ai/core/session/projector"
2026-04-03 00:56:56 +00:00
2026-04-17 01:55:50 +00:00
import { AccessToken , AccountID , OrgID , RefreshToken } from "../../src/account/schema"
2026-04-03 00:56:56 +00:00
import { AccountRepo } from "../../src/account/repo"
2026-05-31 01:08:38 +00:00
import { EventV2Bridge } from "../../src/event-v2-bridge"
2026-04-27 18:33:33 +00:00
import { Session } from "@/session/session"
2026-04-03 00:56:56 +00:00
import type { SessionID } from "../../src/session/schema"
2026-04-27 18:33:33 +00:00
import { ShareNext } from "@/share/share-next"
2026-05-31 01:08:38 +00:00
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { Database } from "@opencode-ai/core/database/database"
2026-04-27 18:33:33 +00:00
import { eq } from "drizzle-orm"
2026-04-03 00:56:56 +00:00
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
2026-06-04 06:57:43 +00:00
import { pollWithTimeout , testEffect } from "../lib/effect"
2026-03-10 16:53:47 +00:00
2026-06-27 16:29:21 +00:00
const env = LayerNode . compile ( LayerNode . group ( [ CrossSpawnSpawner . node ] ) )
2026-04-03 00:56:56 +00:00
const it = testEffect ( env )
const json = ( req : Parameters < typeof HttpClientResponse.fromWeb > [ 0 ] , body : unknown , status = 200 ) = >
HttpClientResponse . fromWeb (
req ,
new Response ( JSON . stringify ( body ) , {
status ,
headers : { "content-type" : "application/json" } ,
} ) ,
)
const none = HttpClient . make ( ( ) = > Effect . die ( "unexpected http call" ) )
2026-06-11 02:19:06 +00:00
function requestLayer ( client : HttpClient.HttpClient ) {
2026-06-29 03:48:18 +00:00
const replacement = [ httpClient , Layer . succeed ( HttpClient . HttpClient , client ) ] as const
2026-06-29 03:49:43 +00:00
return LayerNode . compile ( LayerNode . group ( [ ShareNext . node , AccountRepo . node ] ) , [ replacement ] )
2026-04-03 00:56:56 +00:00
}
2026-06-11 02:19:06 +00:00
function integrationLayer ( client : HttpClient.HttpClient ) {
2026-06-29 03:48:18 +00:00
const replacement = [ httpClient , Layer . succeed ( HttpClient . HttpClient , client ) ] as const
2026-06-27 16:29:21 +00:00
return LayerNode . compile (
2026-06-27 02:47:33 +00:00
LayerNode . group ( [
ShareNext . node ,
EventV2Bridge . node ,
Session . node ,
SessionProjector . node ,
AccountRepo . node ,
Database . node ,
] ) ,
2026-06-29 03:48:18 +00:00
[ replacement ] ,
2026-04-03 00:56:56 +00:00
)
}
const share = ( id : SessionID ) = >
2026-05-31 01:08:38 +00:00
Effect . gen ( function * ( ) {
const { db } = yield * Database . Service
2026-05-31 01:09:55 +00:00
return yield * db
. select ( )
. from ( SessionShareTable )
. where ( eq ( SessionShareTable . session_id , id ) )
. get ( )
. pipe ( Effect . orDie )
2026-05-31 01:08:38 +00:00
} )
2026-03-10 16:53:47 +00:00
2026-04-03 00:56:56 +00:00
const seed = ( url : string , org? : string ) = >
2026-04-17 01:43:57 +00:00
AccountRepo . Service . use ( ( repo ) = >
2026-04-03 00:56:56 +00:00
repo . persistAccount ( {
id : AccountID.make ( "account-1" ) ,
email : "user@example.com" ,
url ,
accessToken : AccessToken.make ( "st_test_token" ) ,
refreshToken : RefreshToken.make ( "rt_test_token" ) ,
expiry : Date.now ( ) + 10 * 60 _000 ,
orgID : org ? Option . some ( OrgID . make ( org ) ) : Option . none ( ) ,
} ) ,
)
beforeEach ( async ( ) = > {
await resetDatabase ( )
2026-03-10 16:53:47 +00:00
} )
2026-04-03 00:56:56 +00:00
describe ( "ShareNext" , ( ) = > {
it . live ( "request uses legacy share API without active org account" , ( ) = >
provideTmpdirInstance (
( ) = >
ShareNext . Service . use ( ( svc ) = >
Effect . gen ( function * ( ) {
const req = yield * svc . request ( )
expect ( req . api . create ) . toBe ( "/api/share" )
expect ( req . api . sync ( "shr_123" ) ) . toBe ( "/api/share/shr_123/sync" )
expect ( req . api . remove ( "shr_123" ) ) . toBe ( "/api/share/shr_123" )
expect ( req . api . data ( "shr_123" ) ) . toBe ( "/api/share/shr_123/data" )
expect ( req . baseUrl ) . toBe ( "https://legacy-share.example.com" )
expect ( req . headers ) . toEqual ( { } )
} ) ,
2026-06-11 02:19:06 +00:00
) . pipe ( Effect . provide ( requestLayer ( none ) ) ) ,
2026-04-03 00:56:56 +00:00
{ config : { enterprise : { url : "https://legacy-share.example.com" } } } ,
) ,
)
it . live ( "request uses default URL when no enterprise config" , ( ) = >
provideTmpdirInstance ( ( ) = >
ShareNext . Service . use ( ( svc ) = >
Effect . gen ( function * ( ) {
const req = yield * svc . request ( )
expect ( req . baseUrl ) . toBe ( "https://opncd.ai" )
expect ( req . api . create ) . toBe ( "/api/share" )
expect ( req . headers ) . toEqual ( { } )
} ) ,
2026-06-11 02:19:06 +00:00
) . pipe ( Effect . provide ( requestLayer ( none ) ) ) ,
2026-04-03 00:56:56 +00:00
) ,
)
it . live ( "request uses org share API with auth headers when account is active" , ( ) = >
provideTmpdirInstance ( ( ) = >
Effect . gen ( function * ( ) {
yield * seed ( "https://control.example.com" , "org-1" )
2026-06-11 02:19:06 +00:00
const req = yield * ShareNext . use . request ( )
2026-04-03 00:56:56 +00:00
expect ( req . api . create ) . toBe ( "/api/shares" )
expect ( req . api . sync ( "shr_123" ) ) . toBe ( "/api/shares/shr_123/sync" )
expect ( req . api . remove ( "shr_123" ) ) . toBe ( "/api/shares/shr_123" )
expect ( req . api . data ( "shr_123" ) ) . toBe ( "/api/shares/shr_123/data" )
expect ( req . baseUrl ) . toBe ( "https://control.example.com" )
expect ( req . headers ) . toEqual ( {
authorization : "Bearer st_test_token" ,
"x-org-id" : "org-1" ,
} )
2026-06-11 02:19:06 +00:00
} ) . pipe ( Effect . provide ( requestLayer ( none ) ) ) ,
2026-04-03 00:56:56 +00:00
) ,
)
it . live ( "create posts share, persists it, and returns the result" , ( ) = >
provideTmpdirInstance (
2026-06-11 02:19:06 +00:00
( ) = > {
2026-06-16 16:53:57 +00:00
const createRequests : HttpClientRequest.HttpClientRequest [ ] = [ ]
2026-06-11 02:19:06 +00:00
const client = HttpClient . make ( ( req ) = > {
if ( req . url . endsWith ( "/api/share" ) ) {
2026-06-16 16:53:57 +00:00
createRequests . push ( req )
2026-06-11 02:19:06 +00:00
return Effect . succeed (
json ( req , {
id : "shr_abc" ,
url : "https://legacy-share.example.com/share/abc" ,
secret : "sec_123" ,
} ) ,
)
}
return Effect . succeed ( json ( req , { ok : true } ) )
} )
return Effect . gen ( function * ( ) {
const session = yield * ( yield * Session . Service ) . create ( { title : "test" } )
2026-04-03 00:56:56 +00:00
2026-06-11 02:19:06 +00:00
const result = yield * ( yield * ShareNext . Service ) . create ( session . id )
2026-04-03 00:56:56 +00:00
expect ( result . id ) . toBe ( "shr_abc" )
expect ( result . url ) . toBe ( "https://legacy-share.example.com/share/abc" )
expect ( result . secret ) . toBe ( "sec_123" )
2026-05-31 01:08:38 +00:00
const row = yield * share ( session . id )
2026-04-03 00:56:56 +00:00
expect ( row ? . id ) . toBe ( "shr_abc" )
expect ( row ? . url ) . toBe ( "https://legacy-share.example.com/share/abc" )
expect ( row ? . secret ) . toBe ( "sec_123" )
2026-06-16 16:53:57 +00:00
expect ( createRequests ) . toHaveLength ( 1 )
expect ( createRequests [ 0 ] . method ) . toBe ( "POST" )
expect ( createRequests [ 0 ] . url ) . toBe ( "https://legacy-share.example.com/api/share" )
2026-06-11 02:19:06 +00:00
} ) . pipe ( Effect . provide ( integrationLayer ( client ) ) )
} ,
2026-04-03 00:56:56 +00:00
{ config : { enterprise : { url : "https://legacy-share.example.com" } } } ,
) ,
)
it . live ( "remove deletes the persisted share and calls the delete endpoint" , ( ) = >
provideTmpdirInstance (
2026-06-11 02:19:06 +00:00
( ) = > {
const seen : HttpClientRequest.HttpClientRequest [ ] = [ ]
const client = HttpClient . make ( ( req ) = > {
seen . push ( req )
if ( req . method === "POST" ) {
return Effect . succeed (
json ( req , {
id : "shr_abc" ,
url : "https://legacy-share.example.com/share/abc" ,
secret : "sec_123" ,
} ) ,
)
}
return Effect . succeed ( HttpClientResponse . fromWeb ( req , new Response ( null , { status : 200 } ) ) )
} )
return Effect . gen ( function * ( ) {
const session = yield * ( yield * Session . Service ) . create ( { title : "test" } )
const service = yield * ShareNext . Service
2026-04-03 00:56:56 +00:00
2026-06-11 02:19:06 +00:00
yield * service . create ( session . id )
yield * service . remove ( session . id )
2026-04-03 00:56:56 +00:00
2026-05-31 01:08:38 +00:00
expect ( yield * share ( session . id ) ) . toBeUndefined ( )
2026-04-03 00:56:56 +00:00
expect ( seen . map ( ( req ) = > [ req . method , req . url ] ) ) . toEqual ( [
[ "POST" , "https://legacy-share.example.com/api/share" ] ,
[ "DELETE" , "https://legacy-share.example.com/api/share/shr_abc" ] ,
] )
2026-06-11 02:19:06 +00:00
} ) . pipe ( Effect . provide ( integrationLayer ( client ) ) )
} ,
2026-04-03 00:56:56 +00:00
{ config : { enterprise : { url : "https://legacy-share.example.com" } } } ,
) ,
)
it . live ( "create fails on a non-ok response and does not persist a share" , ( ) = >
2026-06-11 02:19:06 +00:00
provideTmpdirInstance ( ( ) = > {
const client = HttpClient . make ( ( req ) = > Effect . succeed ( json ( req , { error : "bad" } , 500 ) ) )
return Effect . gen ( function * ( ) {
const session = yield * ( yield * Session . Service ) . create ( { title : "test" } )
2026-04-03 00:56:56 +00:00
2026-06-11 02:19:06 +00:00
const exit = yield * ShareNext . Service . use ( ( svc ) = > Effect . exit ( svc . create ( session . id ) ) )
2026-04-03 00:56:56 +00:00
expect ( Exit . isFailure ( exit ) ) . toBe ( true )
2026-05-31 01:08:38 +00:00
expect ( yield * share ( session . id ) ) . toBeUndefined ( )
2026-06-11 02:19:06 +00:00
} ) . pipe ( Effect . provide ( integrationLayer ( client ) ) )
} ) ,
2026-04-03 00:56:56 +00:00
)
it . live ( "ShareNext coalesces rapid diff events into one delayed sync with latest data" , ( ) = >
provideTmpdirInstance (
( ) = > {
const seen : Array < { url : string ; body : string } > = [ ]
const client = HttpClient . make ( ( req ) = > {
if ( req . url . endsWith ( "/sync" ) && req . body . _tag === "Uint8Array" ) {
seen . push ( { url : req.url , body : new TextDecoder ( ) . decode ( req . body . body ) } )
}
return Effect . succeed ( json ( req , { ok : true } ) )
} )
return Effect . gen ( function * ( ) {
2026-05-31 01:08:38 +00:00
const events = yield * EventV2Bridge . Service
2026-04-03 00:56:56 +00:00
const share = yield * ShareNext . Service
const session = yield * Session . Service
const info = yield * session . create ( { title : "first" } )
yield * share . init ( )
yield * Effect . sleep ( 50 )
2026-05-31 01:08:38 +00:00
const { db } = yield * Database . Service
yield * db
. insert ( SessionShareTable )
. values ( {
session_id : info.id ,
id : "shr_abc" ,
url : "https://legacy-share.example.com/share/abc" ,
secret : "sec_123" ,
} )
. run ( )
. pipe ( Effect . orDie )
yield * events . publish ( Session . Event . Diff , {
2026-04-03 00:56:56 +00:00
sessionID : info.id ,
diff : [
{
file : "a.ts" ,
2026-04-07 23:48:23 +00:00
patch :
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n" ,
2026-04-03 00:56:56 +00:00
additions : 1 ,
deletions : 1 ,
status : "modified" ,
} ,
] ,
} )
2026-05-31 01:08:38 +00:00
yield * events . publish ( Session . Event . Diff , {
2026-04-03 00:56:56 +00:00
sessionID : info.id ,
diff : [
{
file : "b.ts" ,
2026-04-07 23:48:23 +00:00
patch :
"Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n" ,
2026-04-03 00:56:56 +00:00
additions : 2 ,
deletions : 0 ,
status : "modified" ,
} ,
] ,
} )
2026-06-04 06:57:43 +00:00
yield * pollWithTimeout (
Effect . sync ( ( ) = > ( seen . length === 1 ? true : undefined ) ) ,
"timed out waiting for share sync" ,
"5 seconds" ,
)
2026-04-03 00:56:56 +00:00
expect ( seen ) . toHaveLength ( 1 )
expect ( seen [ 0 ] . url ) . toBe ( "https://legacy-share.example.com/api/share/shr_abc/sync" )
const body = JSON . parse ( seen [ 0 ] . body ) as {
secret : string
data : Array < {
type : string
data : Array < {
file : string
2026-04-07 23:48:23 +00:00
patch : string
2026-04-03 00:56:56 +00:00
additions : number
deletions : number
status? : string
} >
} >
}
expect ( body . secret ) . toBe ( "sec_123" )
expect ( body . data ) . toHaveLength ( 1 )
expect ( body . data [ 0 ] . type ) . toBe ( "session_diff" )
expect ( body . data [ 0 ] . data ) . toEqual ( [
{
file : "b.ts" ,
2026-04-07 23:48:23 +00:00
patch :
"Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n" ,
2026-04-03 00:56:56 +00:00
additions : 2 ,
deletions : 0 ,
status : "modified" ,
} ,
] )
2026-06-11 02:19:06 +00:00
} ) . pipe ( Effect . provide ( integrationLayer ( client ) ) )
2026-04-03 00:56:56 +00:00
} ,
{ config : { enterprise : { url : "https://legacy-share.example.com" } } } ,
) ,
)
2026-03-10 16:53:47 +00:00
} )