No known key found for this signature in database
GPG Key ID: 66C92B1C5B475512
41 changed files with 1649 additions and 230 deletions
-
192docs/openapi.yaml
-
21readme.md
-
2src/.air.toml
-
4src/config/settings.go
-
19src/domains/group/group.go
-
1src/domains/send/audio.go
-
1src/domains/send/contact.go
-
1src/domains/send/file.go
-
1src/domains/send/image.go
-
1src/domains/send/link.go
-
1src/domains/send/location.go
-
1src/domains/send/presence.go
-
1src/domains/send/text.go
-
1src/domains/send/video.go
-
4src/domains/user/account.go
-
1src/domains/user/user.go
-
47src/go.mod
-
120src/go.sum
-
81src/internal/rest/group.go
-
16src/internal/rest/user.go
-
155src/pkg/utils/chat_storage_test.go
-
185src/pkg/utils/environment_test.go
-
126src/pkg/utils/general.go
-
66src/services/group.go
-
3src/services/message.go
-
102src/services/send.go
-
11src/services/user.go
-
29src/validations/group_validation.go
-
4src/views/assets/app.css
-
96src/views/components/AccountChangePushName.js
-
35src/views/components/AccountContact.js
-
134src/views/components/GroupList.js
-
14src/views/components/SendAudio.js
-
20src/views/components/SendContact.js
-
15src/views/components/SendFile.js
-
20src/views/components/SendImage.js
-
148src/views/components/SendLink.js
-
15src/views/components/SendLocation.js
-
10src/views/components/SendMessage.js
-
18src/views/components/SendVideo.js
-
13src/views/index.html
@ -0,0 +1,155 @@ |
|||
package utils_test |
|||
|
|||
import ( |
|||
"encoding/csv" |
|||
"os" |
|||
"path/filepath" |
|||
"testing" |
|||
|
|||
"github.com/aldinokemal/go-whatsapp-web-multidevice/config" |
|||
. "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/suite" |
|||
) |
|||
|
|||
type ChatStorageTestSuite struct { |
|||
suite.Suite |
|||
tempDir string |
|||
origStorage bool |
|||
origPath string |
|||
} |
|||
|
|||
func (suite *ChatStorageTestSuite) SetupTest() { |
|||
// Create a temporary directory for test files
|
|||
tempDir, err := os.MkdirTemp("", "chat_storage_test") |
|||
assert.NoError(suite.T(), err) |
|||
suite.tempDir = tempDir |
|||
|
|||
// Save original config values
|
|||
suite.origStorage = config.WhatsappChatStorage |
|||
suite.origPath = config.PathChatStorage |
|||
|
|||
// Set test config values
|
|||
config.WhatsappChatStorage = true |
|||
config.PathChatStorage = filepath.Join(tempDir, "chat_storage.csv") |
|||
} |
|||
|
|||
func (suite *ChatStorageTestSuite) TearDownTest() { |
|||
// Restore original config values
|
|||
config.WhatsappChatStorage = suite.origStorage |
|||
config.PathChatStorage = suite.origPath |
|||
|
|||
// Clean up temp directory
|
|||
os.RemoveAll(suite.tempDir) |
|||
} |
|||
|
|||
func (suite *ChatStorageTestSuite) createTestData() { |
|||
// Create test CSV data
|
|||
file, err := os.Create(config.PathChatStorage) |
|||
assert.NoError(suite.T(), err) |
|||
defer file.Close() |
|||
|
|||
writer := csv.NewWriter(file) |
|||
defer writer.Flush() |
|||
|
|||
testData := [][]string{ |
|||
{"msg1", "user1@test.com", "Hello world"}, |
|||
{"msg2", "user2@test.com", "Test message"}, |
|||
} |
|||
|
|||
err = writer.WriteAll(testData) |
|||
assert.NoError(suite.T(), err) |
|||
} |
|||
|
|||
func (suite *ChatStorageTestSuite) TestFindRecordFromStorage() { |
|||
// Test case: Record found
|
|||
suite.createTestData() |
|||
record, err := FindRecordFromStorage("msg1") |
|||
assert.NoError(suite.T(), err) |
|||
assert.Equal(suite.T(), "msg1", record.MessageID) |
|||
assert.Equal(suite.T(), "user1@test.com", record.JID) |
|||
assert.Equal(suite.T(), "Hello world", record.MessageContent) |
|||
|
|||
// Test case: Record not found
|
|||
_, err = FindRecordFromStorage("non_existent") |
|||
assert.Error(suite.T(), err) |
|||
assert.Contains(suite.T(), err.Error(), "not found in storage") |
|||
|
|||
// Test case: Empty file - should still report message not found
|
|||
os.Remove(config.PathChatStorage) |
|||
_, err = FindRecordFromStorage("msg1") |
|||
assert.Error(suite.T(), err) |
|||
assert.Contains(suite.T(), err.Error(), "not found in storage") |
|||
|
|||
// Test case: Corrupted CSV file - should return CSV parsing error
|
|||
err = os.WriteFile(config.PathChatStorage, []byte("corrupted,csv,data\nwith,no,proper,format"), 0644) |
|||
assert.NoError(suite.T(), err) |
|||
_, err = FindRecordFromStorage("msg1") |
|||
assert.Error(suite.T(), err) |
|||
assert.Contains(suite.T(), err.Error(), "failed to read CSV records") |
|||
|
|||
// Test case: File permissions issue
|
|||
// Create an unreadable directory for testing file permission issues
|
|||
unreadableDir := filepath.Join(suite.tempDir, "unreadable") |
|||
err = os.Mkdir(unreadableDir, 0000) |
|||
assert.NoError(suite.T(), err) |
|||
defer os.Chmod(unreadableDir, 0755) // So it can be deleted during teardown
|
|||
|
|||
// Temporarily change path to unreadable location
|
|||
origPath := config.PathChatStorage |
|||
config.PathChatStorage = filepath.Join(unreadableDir, "inaccessible.csv") |
|||
_, err = FindRecordFromStorage("anything") |
|||
assert.Error(suite.T(), err) |
|||
assert.Contains(suite.T(), err.Error(), "failed to open storage file") |
|||
|
|||
// Restore path
|
|||
config.PathChatStorage = origPath |
|||
} |
|||
|
|||
func (suite *ChatStorageTestSuite) TestRecordMessage() { |
|||
// Test case: Normal recording
|
|||
err := RecordMessage("newMsg", "user@test.com", "New test message") |
|||
assert.NoError(suite.T(), err) |
|||
|
|||
// Verify the message was recorded
|
|||
record, err := FindRecordFromStorage("newMsg") |
|||
assert.NoError(suite.T(), err) |
|||
assert.Equal(suite.T(), "newMsg", record.MessageID) |
|||
assert.Equal(suite.T(), "user@test.com", record.JID) |
|||
assert.Equal(suite.T(), "New test message", record.MessageContent) |
|||
|
|||
// Test case: Duplicate message ID
|
|||
err = RecordMessage("newMsg", "user@test.com", "Duplicate message") |
|||
assert.NoError(suite.T(), err) |
|||
|
|||
// Verify the duplicate wasn't added
|
|||
record, err = FindRecordFromStorage("newMsg") |
|||
assert.NoError(suite.T(), err) |
|||
assert.Equal(suite.T(), "New test message", record.MessageContent, "Should not update existing record") |
|||
|
|||
// Test case: Disabled storage
|
|||
config.WhatsappChatStorage = false |
|||
err = RecordMessage("anotherMsg", "user@test.com", "Should not be stored") |
|||
assert.NoError(suite.T(), err) |
|||
|
|||
config.WhatsappChatStorage = true // Re-enable for next tests
|
|||
_, err = FindRecordFromStorage("anotherMsg") |
|||
assert.Error(suite.T(), err, "Message should not be found when storage is disabled") |
|||
|
|||
// Test case: Write permission error - Alternative approach to avoid platform-specific issues
|
|||
// Instead of creating an unwritable file, we'll temporarily set PathChatStorage to a non-existent directory
|
|||
nonExistentPath := filepath.Join(suite.tempDir, "non-existent-dir", "test.csv") |
|||
origPath := config.PathChatStorage |
|||
config.PathChatStorage = nonExistentPath |
|||
|
|||
err = RecordMessage("failMsg", "user@test.com", "Should fail to write") |
|||
assert.Error(suite.T(), err) |
|||
assert.Contains(suite.T(), err.Error(), "failed to open file for writing") |
|||
|
|||
// Restore path
|
|||
config.PathChatStorage = origPath |
|||
} |
|||
|
|||
func TestChatStorageTestSuite(t *testing.T) { |
|||
suite.Run(t, new(ChatStorageTestSuite)) |
|||
} |
|||
@ -0,0 +1,185 @@ |
|||
package utils_test |
|||
|
|||
import ( |
|||
"os" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils" |
|||
"github.com/spf13/viper" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/suite" |
|||
) |
|||
|
|||
type EnvironmentTestSuite struct { |
|||
suite.Suite |
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) SetupTest() { |
|||
// Clear any existing viper configs
|
|||
viper.Reset() |
|||
// Set up automatic environment variable reading
|
|||
viper.AutomaticEnv() |
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TearDownTest() { |
|||
viper.Reset() |
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestIsLocal() { |
|||
tests := []struct { |
|||
name string |
|||
envValue string |
|||
expected bool |
|||
}{ |
|||
{"Production environment", "production", false}, |
|||
{"Staging environment", "staging", false}, |
|||
{"Integration environment", "integration", false}, |
|||
{"Development environment", "development", true}, |
|||
{"Local environment", "local", true}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
suite.T().Run(tt.name, func(t *testing.T) { |
|||
// Set the environment value
|
|||
if tt.envValue != "" { |
|||
viper.Set("APP_ENV", tt.envValue) |
|||
} else { |
|||
viper.Set("APP_ENV", nil) // Explicitly clear the value
|
|||
} |
|||
|
|||
result := utils.IsLocal() |
|||
assert.Equal(t, tt.expected, result) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestEnv() { |
|||
// Test with existing value
|
|||
viper.Set("TEST_KEY", "test_value") |
|||
result := utils.Env[string]("TEST_KEY") |
|||
assert.Equal(suite.T(), "test_value", result) |
|||
|
|||
// Test with default value
|
|||
result = utils.Env("NON_EXISTENT_KEY", "default_value") |
|||
assert.Equal(suite.T(), "default_value", result) |
|||
|
|||
// Test with integer
|
|||
viper.Set("TEST_INT", 42) |
|||
intResult := utils.Env[int]("TEST_INT") |
|||
assert.Equal(suite.T(), 42, intResult) |
|||
|
|||
// Test with default integer
|
|||
intResult = utils.Env("NON_EXISTENT_INT", 100) |
|||
assert.Equal(suite.T(), 100, intResult) |
|||
|
|||
// Test with boolean
|
|||
viper.Set("TEST_BOOL", true) |
|||
boolResult := utils.Env[bool]("TEST_BOOL") |
|||
assert.Equal(suite.T(), true, boolResult) |
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestMustHaveEnv() { |
|||
// Test with value present
|
|||
viper.Set("REQUIRED_ENV", "required_value") |
|||
result := utils.MustHaveEnv("REQUIRED_ENV") |
|||
assert.Equal(suite.T(), "required_value", result) |
|||
|
|||
// Create a temporary .env file for testing
|
|||
tempEnvContent := []byte("ENV_FROM_FILE=env_file_value\n") |
|||
err := os.WriteFile(".env", tempEnvContent, 0644) |
|||
assert.NoError(suite.T(), err) |
|||
defer os.Remove(".env") |
|||
|
|||
// Test reading from .env file
|
|||
result = utils.MustHaveEnv("ENV_FROM_FILE") |
|||
assert.Equal(suite.T(), "env_file_value", result) |
|||
|
|||
// We can't easily test the fatal log scenario in a unit test
|
|||
// as it would terminate the program
|
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestMustHaveEnvBool() { |
|||
// Test true value
|
|||
viper.Set("BOOL_TRUE", "true") |
|||
result := utils.MustHaveEnvBool("BOOL_TRUE") |
|||
assert.True(suite.T(), result) |
|||
|
|||
// Test false value
|
|||
viper.Set("BOOL_FALSE", "false") |
|||
result = utils.MustHaveEnvBool("BOOL_FALSE") |
|||
assert.False(suite.T(), result) |
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestMustHaveEnvInt() { |
|||
// Test valid integer
|
|||
viper.Set("INT_VALUE", "42") |
|||
result := utils.MustHaveEnvInt("INT_VALUE") |
|||
assert.Equal(suite.T(), 42, result) |
|||
|
|||
// Test zero
|
|||
viper.Set("ZERO_INT", "0") |
|||
result = utils.MustHaveEnvInt("ZERO_INT") |
|||
assert.Equal(suite.T(), 0, result) |
|||
|
|||
// Test negative number
|
|||
viper.Set("NEG_INT", "-10") |
|||
result = utils.MustHaveEnvInt("NEG_INT") |
|||
assert.Equal(suite.T(), -10, result) |
|||
|
|||
// We can't easily test the fatal log scenario with invalid int
|
|||
// as it would terminate the program
|
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestMustHaveEnvMinuteDuration() { |
|||
// Test valid duration
|
|||
viper.Set("DURATION_MIN", "5") |
|||
result := utils.MustHaveEnvMinuteDuration("DURATION_MIN") |
|||
assert.Equal(suite.T(), 5*time.Minute, result) |
|||
|
|||
// Test zero duration
|
|||
viper.Set("ZERO_DURATION", "0") |
|||
result = utils.MustHaveEnvMinuteDuration("ZERO_DURATION") |
|||
assert.Equal(suite.T(), 0*time.Minute, result) |
|||
|
|||
// We can't easily test the fatal log scenario with invalid duration
|
|||
// as it would terminate the program
|
|||
} |
|||
|
|||
func (suite *EnvironmentTestSuite) TestLoadConfig() { |
|||
// Create a temporary config file for testing
|
|||
tempDir, err := os.MkdirTemp("", "config_test") |
|||
assert.NoError(suite.T(), err) |
|||
defer os.RemoveAll(tempDir) |
|||
|
|||
// Create test config file
|
|||
configContent := []byte("TEST_CONFIG=config_value\n") |
|||
configPath := tempDir + "/.env" |
|||
err = os.WriteFile(configPath, configContent, 0644) |
|||
assert.NoError(suite.T(), err) |
|||
|
|||
// Test loading config with default name
|
|||
err = utils.LoadConfig(tempDir) |
|||
assert.NoError(suite.T(), err) |
|||
assert.Equal(suite.T(), "config_value", viper.GetString("TEST_CONFIG")) |
|||
|
|||
// Test loading config with custom name
|
|||
customConfigContent := []byte("CUSTOM_CONFIG=custom_value\n") |
|||
customConfigPath := tempDir + "/custom.env" |
|||
err = os.WriteFile(customConfigPath, customConfigContent, 0644) |
|||
assert.NoError(suite.T(), err) |
|||
|
|||
viper.Reset() |
|||
err = utils.LoadConfig(tempDir, "custom") |
|||
assert.NoError(suite.T(), err) |
|||
assert.Equal(suite.T(), "custom_value", viper.GetString("CUSTOM_CONFIG")) |
|||
|
|||
// Test error case - non-existent directory
|
|||
viper.Reset() |
|||
err = utils.LoadConfig("/non/existent/directory") |
|||
assert.Error(suite.T(), err) |
|||
} |
|||
|
|||
func TestEnvironmentTestSuite(t *testing.T) { |
|||
suite.Run(t, new(EnvironmentTestSuite)) |
|||
} |
|||
@ -0,0 +1,96 @@ |
|||
export default { |
|||
name: 'AccountChangePushName', |
|||
data() { |
|||
return { |
|||
loading: false, |
|||
push_name: '' |
|||
} |
|||
}, |
|||
methods: { |
|||
openModal() { |
|||
$('#modalChangePushName').modal({ |
|||
onApprove: function () { |
|||
return false; |
|||
} |
|||
}).modal('show'); |
|||
}, |
|||
isValidForm() { |
|||
return this.push_name.trim() !== ''; |
|||
}, |
|||
async handleSubmit() { |
|||
if (!this.isValidForm() || this.loading) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
let response = await this.submitApi() |
|||
showSuccessInfo(response) |
|||
$('#modalChangePushName').modal('hide'); |
|||
} catch (err) { |
|||
showErrorInfo(err) |
|||
} |
|||
}, |
|||
async submitApi() { |
|||
this.loading = true; |
|||
try { |
|||
let payload = { |
|||
push_name: this.push_name |
|||
} |
|||
|
|||
let response = await window.http.post(`/user/pushname`, payload) |
|||
this.handleReset(); |
|||
return response.data.message; |
|||
} catch (error) { |
|||
if (error.response) { |
|||
throw new Error(error.response.data.message); |
|||
} |
|||
throw new Error(error.message); |
|||
} finally { |
|||
this.loading = false; |
|||
} |
|||
}, |
|||
handleReset() { |
|||
this.push_name = ''; |
|||
} |
|||
}, |
|||
template: `
|
|||
<div class="olive card" @click="openModal()" style="cursor:pointer;"> |
|||
<div class="content"> |
|||
<a class="ui olive right ribbon label">Account</a> |
|||
<div class="header">Change Push Name</div> |
|||
<div class="description"> |
|||
Update your WhatsApp display name |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Modal Change Push Name --> |
|||
<div class="ui small modal" id="modalChangePushName"> |
|||
<i class="close icon"></i> |
|||
<div class="header"> |
|||
Change Push Name |
|||
</div> |
|||
<div class="content" style="max-height: 70vh; overflow-y: auto;"> |
|||
<div class="ui info message"> |
|||
<i class="info circle icon"></i> |
|||
Your push name is the display name shown to others in WhatsApp. |
|||
</div> |
|||
|
|||
<form class="ui form"> |
|||
<div class="field"> |
|||
<label>New Push Name</label> |
|||
<input type="text" v-model="push_name" placeholder="Enter your new display name"> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="actions"> |
|||
<button class="ui approve positive right labeled icon button" |
|||
:class="{'loading': this.loading, 'disabled': !isValidForm() || loading}" |
|||
@click.prevent="handleSubmit"> |
|||
Update Push Name |
|||
<i class="save icon"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`
|
|||
} |
|||
@ -0,0 +1,148 @@ |
|||
import FormRecipient from "./generic/FormRecipient.js"; |
|||
|
|||
export default { |
|||
name: 'SendLink', |
|||
components: { |
|||
FormRecipient |
|||
}, |
|||
data() { |
|||
return { |
|||
type: window.TYPEUSER, |
|||
phone: '', |
|||
link: '', |
|||
caption: '', |
|||
reply_message_id: '', |
|||
loading: false, |
|||
is_forwarded: false |
|||
} |
|||
}, |
|||
computed: { |
|||
phone_id() { |
|||
return this.phone + this.type; |
|||
}, |
|||
}, |
|||
methods: { |
|||
openModal() { |
|||
$('#modalSendLink').modal({ |
|||
onApprove: function () { |
|||
return false; |
|||
} |
|||
}).modal('show'); |
|||
}, |
|||
isShowReplyId() { |
|||
return this.type !== window.TYPESTATUS; |
|||
}, |
|||
isValidForm() { |
|||
// Validate phone number is not empty except for status type
|
|||
const isPhoneValid = this.type === window.TYPESTATUS || this.phone.trim().length > 0; |
|||
|
|||
// Validate link is not empty and has reasonable length
|
|||
const isLinkValid = this.link.trim().length > 0 && this.link.length <= 4096; |
|||
|
|||
// Validate caption is not empty and has reasonable length
|
|||
const isCaptionValid = this.caption.trim().length > 0 && this.caption.length <= 4096; |
|||
|
|||
return isPhoneValid && isLinkValid && isCaptionValid |
|||
}, |
|||
async handleSubmit() { |
|||
// Add validation check here to prevent submission when form is invalid
|
|||
if (!this.isValidForm() || this.loading) { |
|||
return; |
|||
} |
|||
try { |
|||
const response = await this.submitApi(); |
|||
showSuccessInfo(response); |
|||
$('#modalSendLink').modal('hide'); |
|||
} catch (err) { |
|||
showErrorInfo(err); |
|||
} |
|||
}, |
|||
async submitApi() { |
|||
this.loading = true; |
|||
try { |
|||
const payload = { |
|||
phone: this.phone_id, |
|||
link: this.link.trim(), |
|||
caption: this.caption.trim(), |
|||
is_forwarded: this.is_forwarded |
|||
}; |
|||
if (this.reply_message_id !== '') { |
|||
payload.reply_message_id = this.reply_message_id; |
|||
} |
|||
|
|||
const response = await window.http.post('/send/link', payload); |
|||
this.handleReset(); |
|||
return response.data.message; |
|||
} catch (error) { |
|||
if (error.response?.data?.message) { |
|||
throw new Error(error.response.data.message); |
|||
} |
|||
throw error; |
|||
} finally { |
|||
this.loading = false; |
|||
} |
|||
}, |
|||
handleReset() { |
|||
this.phone = ''; |
|||
this.link = ''; |
|||
this.caption = ''; |
|||
this.reply_message_id = ''; |
|||
this.is_forwarded = false; |
|||
}, |
|||
}, |
|||
template: `
|
|||
<div class="blue card" @click="openModal()" style="cursor: pointer"> |
|||
<div class="content"> |
|||
<a class="ui blue right ribbon label">Send</a> |
|||
<div class="header">Send Link</div> |
|||
<div class="description"> |
|||
Send link to user or group |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Modal SendLink --> |
|||
<div class="ui small modal" id="modalSendLink"> |
|||
<i class="close icon"></i> |
|||
<div class="header"> |
|||
Send Link |
|||
</div> |
|||
<div class="content"> |
|||
<form class="ui form"> |
|||
<FormRecipient v-model:type="type" v-model:phone="phone" :show-status="true"/> |
|||
<div class="field" v-if="isShowReplyId()"> |
|||
<label>Reply Message ID</label> |
|||
<input v-model="reply_message_id" type="text" |
|||
placeholder="Optional: 57D29F74B7FC62F57D8AC2C840279B5B/3EB0288F008D32FCD0A424" |
|||
aria-label="reply_message_id"> |
|||
</div> |
|||
<div class="field"> |
|||
<label>Link</label> |
|||
<input v-model="link" type="text" placeholder="https://www.google.com" |
|||
aria-label="link"> |
|||
</div> |
|||
<div class="field"> |
|||
<label>Caption</label> |
|||
<textarea v-model="caption" placeholder="Hello this is caption" |
|||
aria-label="caption"></textarea> |
|||
</div> |
|||
<div class="field" v-if="isShowReplyId()"> |
|||
<label>Is Forwarded</label> |
|||
<div class="ui toggle checkbox"> |
|||
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded"> |
|||
<label>Mark link as forwarded</label> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="actions"> |
|||
<button class="ui approve positive right labeled icon button" |
|||
:class="{'disabled': !isValidForm() || loading}" |
|||
@click.prevent="handleSubmit"> |
|||
Send |
|||
<i class="send icon"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`
|
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue